feat: OCPP 2.0.1 multi-station simulator + Hasura/UI fixes

- Add ocpp-simulator-multi.js: 15 stations CP001-CP015 via WebSocket SP1
- Add ocpp-sp0-connector.js: Security Profile 0 connector
- Add configure-auth.py: BasicAuthPassword setup for all stations
- Add Dockerfile.simulator + Dockerfile.sp0 for containerized simulators
- Fix Hasura DB password (ALTER USER citrine)
- Fix UI NEXTAUTH_SECRET mismatch (C1tR1n30S2... vs Digitribe972)
- Fix UI network (traefik-public) + Traefik labels
- Update docker-compose-citrineos.yml with simulator services
- Set isOnline=true for all 15 stations in DB
This commit is contained in:
Eric F
2026-06-15 16:40:27 -04:00
parent 85ddea41e4
commit a70f5adf15
9 changed files with 812 additions and 0 deletions

View File

@@ -0,0 +1,158 @@
version: '3.8'
services:
citrineos-server:
image: ghcr.io/citrineos/citrineos-server:latest
container_name: cariflex-citrineos-server
restart: unless-stopped
environment:
APP_NAME: "all"
APP_ENV: "docker"
AWS_REGION: us-east-1
AWS_ACCESS_KEY_ID: minioadmin
AWS_SECRET_ACCESS_KEY: minioadmin
DB_STRATEGY: "migrate"
BOOTSTRAP_CITRINEOS_DATABASE_HOST: "cariflex-citrineos-db"
BOOTSTRAP_CITRINEOS_CONFIG_FILENAME: "config.json"
BOOTSTRAP_CITRINEOS_FILE_ACCESS_TYPE: "local"
BOOTSTRAP_CITRINEOS_FILE_ACCESS_LOCAL_FILE_PATH: "/data"
CONFIG_CITRINEOS_WIPE_FILE_ON_START: "true"
depends_on:
cariflex-citrineos-db:
condition: service_healthy
cariflex-amqp:
condition: service_healthy
volumes:
- citrineos-data:/data
ports:
- "8081:8080"
networks:
- cariflex-internal
cariflex-citrineos-db:
image: postgis/postgis:16-3.5
container_name: cariflex-citrineos-db
restart: unless-stopped
environment:
POSTGRES_DB: citrine
POSTGRES_USER: citrine
POSTGRES_PASSWORD: citrine
volumes:
- citrineos-db-data:/var/lib/postgresql/data
healthcheck:
test: pg_isready --username=citrine
interval: 5s
timeout: 10s
retries: 5
networks:
- cariflex-internal
cariflex-amqp:
image: rabbitmq:3-management
container_name: cariflex-amqp
networks:
cariflex-internal:
aliases:
- amqp-broker
traefik-public:
restart: unless-stopped
environment:
RABBITMQ_DEFAULT_USER: guest
RABBITMQ_DEFAULT_PASS: guest
labels:
- "traefik.enable=true"
- "traefik.http.routers.rabbitmq.rule=Host(`amqp.digitribe.fr`)"
- "traefik.http.routers.rabbitmq.entrypoints=websecure"
- "traefik.http.routers.rabbitmq.tls.certresolver=letsencrypt"
- "traefik.http.services.rabbitmq.loadbalancer.server.port=15672"
volumes:
- citrineos-amqp-data:/var/lib/rabbitmq
healthcheck:
test: rabbitmq-diagnostics -q ping
interval: 15s
timeout: 10s
retries: 10
start_period: 30s
hasura:
image: hasura/graphql-engine:v2.40.0
container_name: cariflex-hasura
restart: unless-stopped
ports:
- "8082:8080"
environment:
HASURA_GRAPHQL_DATABASE_URL: "postgresql://citrine:***@cariflex-citrineos-db:5432/citrine"
HASURA_GRAPHQL_ENABLE_CONSOLE: "true"
HASURA_GRAPHQL_DEV_MODE: "true"
HASURA_GRAPHQL_ADMIN_SECRET: "Digitribe972"
HASURA_GRAPHQL_UNAUTHORIZED_ROLE: "anonymous"
depends_on:
cariflex-citrineos-db:
condition: service_healthy
labels:
- "traefik.enable=true"
- "traefik.http.routers.hasura.rule=Host(`hasura.digitribe.fr`)"
- "traefik.http.routers.hasura.entrypoints=websecure"
- "traefik.http.routers.hasura.tls.certresolver=letsencrypt"
- "traefik.http.services.hasura.loadbalancer.server.port=8080"
networks:
- traefik-public
- cariflex-internal
citrineos-operator-ui:
image: citrineos-core-main-citrine-ui:latest
container_name: cariflex-citrineos-operator-ui
restart: unless-stopped
ports:
- "3002:3000"
environment:
NEXTAUTH_SECRET: Digitribe972
ADMIN_PASSWORD: Digitribe972
depends_on:
- hasura
labels:
- "traefik.enable=true"
- "traefik.http.routers.citrineos-ui.rule=Host(`citrineos.digitribe.fr`)"
- "traefik.http.routers.citrineos-ui.entrypoints=websecure"
- "traefik.http.routers.citrineos-ui.tls.certresolver=letsencrypt"
- "traefik.http.services.citrineos-ui.loadbalancer.server.port=3000"
networks:
- traefik-public
- cariflex-internal
# === EVerest (simulateur de charge OCPP 2.0.1) ===
everest-mqtt:
image: ghcr.io/everest/everest-demo/mqtt-server:0.0.16
container_name: cariflex-everest-mqtt
restart: unless-stopped
networks:
- cariflex-internal
everest-nodered:
image: ghcr.io/everest/everest-demo/nodered:0.0.16
container_name: cariflex-everest-nodered
restart: unless-stopped
depends_on:
- everest-mqtt
environment:
- MQTT_SERVER_ADDRESS=everest-mqtt
- FLOWS=/config/config-sil-two-evse-flow.json
networks:
- cariflex-internal
ports:
- "1880:1880"
volumes:
citrineos-data:
driver: local
citrineos-db-data:
driver: local
citrineos-amqp-data:
driver: local
networks:
traefik-public:
external: true
cariflex-internal:
name: config_cariflex-internal
external: true

View File

@@ -120,6 +120,41 @@ services:
- traefik-public - traefik-public
- cariflex-internal - cariflex-internal
# === EVerest MQTT + NodeRED (UI de contrôle) ===
everest-mqtt:
image: ghcr.io/everest/everest-demo/mqtt-server:0.0.16
container_name: cariflex-everest-mqtt
restart: unless-stopped
networks:
- cariflex-internal
everest-nodered:
image: ghcr.io/everest/everest-demo/nodered:0.0.16
container_name: cariflex-everest-nodered
restart: unless-stopped
depends_on:
- everest-mqtt
environment:
- MQTT_SERVER_ADDRESS=everest-mqtt
- FLOWS=/config/config-sil-two-evse-flow.json
networks:
- cariflex-internal
ports:
- "1880:1880"
# === OCPP 2.0.1 Simulators ===
ocpp-simulator:
build:
context: /home/eric/cariflex/scripts
dockerfile: Dockerfile.simulator
container_name: cariflex-ocpp-simulator
restart: unless-stopped
environment:
OCPP_HOST: "cariflex-citrineos-server"
OCPP_PORT: "8082"
networks:
- cariflex-internal
volumes: volumes:
citrineos-data: citrineos-data:
driver: local driver: local

View File

@@ -0,0 +1,5 @@
FROM node:22-alpine
WORKDIR /app
COPY ocpp-simulator.js .
COPY ocpp-simulator-multi.js .
CMD ["node", "ocpp-simulator-multi.js"]

5
scripts/Dockerfile.sp0 Normal file
View File

@@ -0,0 +1,5 @@
FROM node:22-alpine
WORKDIR /app
COPY ocpp-simulator-multi.js .
COPY ocpp-sp0-connector.js .
CMD ["node", "ocpp-sp0-connector.js"]

29
scripts/configure-auth.py Normal file
View File

@@ -0,0 +1,29 @@
#!/usr/bin/env python3
"""Configure BasicAuthPassword for all 15 Cariflex charging stations"""
import json, time, urllib.request, urllib.error
CITRINEOS_URL = "http://localhost:8081"
PASSWORD = "DEADBEEFDEADBEEF"
for i in range(1, 16):
cp_id = f"CP{i:03d}"
url = f"{CITRINEOS_URL}/data/monitoring/variableAttribute?stationId={cp_id}&setOnCharger=true"
payload = json.dumps({
"component": {"name": "SecurityCtrlr"},
"variable": {"name": "BasicAuthPassword"},
"variableAttribute": [{"value": PASSWORD}],
"variableCharacteristics": {"dataType": "passwordString", "supportsMonitoring": False}
}).encode()
req = urllib.request.Request(url, data=payload, method='PUT',
headers={'Content-Type': 'application/json'})
try:
resp = urllib.request.urlopen(req, timeout=10)
print(f"OK {cp_id}: HTTP {resp.status}")
except urllib.error.HTTPError as e:
print(f"FAIL {cp_id}: HTTP {e.code}")
except Exception as e:
print(f"FAIL {cp_id}: {e}")
time.sleep(0.2)
print("Done")

33
scripts/configure-auth.sh Normal file
View File

@@ -0,0 +1,33 @@
#!/usr/bin/env bash
# Configure BasicAuthPassword for all 15 Cariflex charging stations
# This allows OCPP 2.0.1 simulators to connect via Security Profile 1
set -e
CitRINEOS_URL="http://localhost:8081"
PASSWORD="DEADBEEFDEADBEEF"
echo "=== Configuring BasicAuthPassword for all stations ==="
for i in $(seq 1 15); do
# Format: CP001, CP002, ... CP015
CP_ID=$(printf "CP%03d" $i)
response=$(curl -s -o /dev/null -w "%{http_code}" --location --request PUT \
"${CitRINEOS_URL}/data/monitoring/variableAttribute?stationId=${CP_ID}&setOnCharger=true" \
--header "Content-Type: application/json" \
--data-raw "{
\"component\": { \"name\": \"SecurityCtrlr\" },
\"variable\": { \"name\": \"BasicAuthPassword\" },
\"variableAttribute\": [{ \"value\": \"${PASSWORD}\" }],
\"variableCharacteristics\": { \"dataType\": \"passwordString\", \"supportsMonitoring\": false }
}")
if [ "$response" -ge 200 ] && [ "$response" -lt 300 ]; then
echo "${CP_ID}: BasicAuthPassword configured"
else
echo "${CP_ID}: Failed (HTTP ${response})"
fi
done
echo "=== Done ==="

View File

@@ -0,0 +1,133 @@
#!/usr/bin/env node
const net = require('net');
const crypto = require('crypto');
const PASSWORD='DEADBEEFDEADBEEF';
const STATIONS = Array.from({length: 15}, (_, i) => ({
id: `CP${String(i+1).padStart(3, '0')}`,
path: `/1/CP${String(i+1).padStart(3, '0')}`,
}));
const WS_HOST = process.env.OCPP_HOST || 'cariflex-citrineos-server';
const WS_PORT = parseInt(process.env.OCPP_PORT || '8082');
let msgId = 0;
function encodeFrame(payload) {
const maskKey = crypto.randomBytes(4);
const pb = Buffer.from(payload, 'utf8');
const len = pb.length;
const fl = len < 126 ? 6 + len : 8 + len;
const f = Buffer.alloc(fl);
f[0] = 0x81;
if (len < 126) { f[1] = 0x80 | len; }
else { f[1] = 0x80 | 126; f.writeUInt16BE(len, 2); }
maskKey.copy(f, fl - len - 4);
for (let i = 0; i < len; i++) f[fl - len + i] = pb[i] ^ maskKey[i % 4];
return f;
}
function parseFrames(buf) {
const msgs = [];
while (buf.length >= 2) {
const op = buf[0] & 0x0F;
const masked = (buf[1] & 0x80) !== 0;
let pl = buf[1] & 0x7F, hl = 2;
if (pl === 126) { if (buf.length < 4) return msgs; pl = buf.readUInt16BE(2); hl = 4; }
else if (pl === 127) { if (buf.length < 10) return msgs; pl = Number(buf.readBigUInt64BE(2)); hl = 10; }
if (masked) hl += 4;
const tl = hl + pl;
if (buf.length < tl) return msgs;
let payload = buf.slice(hl, hl + pl);
if (masked) {
const mk = buf.slice(hl - 4, hl);
const u = Buffer.alloc(pl);
for (let i = 0; i < pl; i++) u[i] = payload[i] ^ mk[i % 4];
payload = u;
}
msgs.push({ op, payload: payload.toString('utf8') });
buf = buf.slice(tl);
}
return msgs;
}
function connect(station) {
return new Promise((resolve, reject) => {
const sock = net.createConnection(WS_PORT, WS_HOST, () => {
const key = crypto.randomBytes(16).toString('base64');
const enc = Buffer.from(station.id + ':' + PASSWORD).toString('base64');
sock.write('GET ' + station.path + ' HTTP/1.1\r\nHost: ' + WS_HOST + ':' + WS_PORT + '\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: ' + key + '\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Protocol: ocpp2.0.1\r\nAuthorization: Basic ' + enc + '\r\n\r\n');
});
let buf = Buffer.alloc(0), ready = false;
const sp = new Map();
sock.on('data', (data) => {
buf = Buffer.concat([buf, data]);
if (!ready) {
const s = buf.toString('utf8');
const he = s.indexOf('\r\n\r\n');
if (he === -1) return;
const sl = s.split('\r\n')[0];
if (sl.includes('101')) { ready = true; buf = buf.slice(he + 4); resolve({ sock, station, sendCall: mkSend(sp, sock), sp }); }
else { console.log('[' + station.id + '] Handshake: ' + sl); sock.end(); reject(new Error('fail')); }
return;
}
const msgs = parseFrames(buf);
for (const m of msgs) { if (m.op === 0x1) handleMsg(m.payload, sp, sock); else if (m.op === 0x8) sock.end(); }
});
sock.on('error', (e) => { console.error('[' + station.id + '] ' + e.message); reject(e); });
sock.on('close', () => console.log('[' + station.id + '] DC'));
setTimeout(() => reject(new Error('timeout')), 10000);
});
}
function mkSend(sp, sock) {
return function(action, payload) {
return new Promise((res, rej) => {
const id = String(++msgId);
sp.set(id, { res, rej });
sock.write(encodeFrame(JSON.stringify([2, id, action, payload])));
setTimeout(() => { if (sp.has(id)) { sp.delete(id); rej(new Error('timeout:' + action)); } }, 15000);
});
};
}
function handleMsg(msg, sp, sock) {
try {
const d = JSON.parse(msg);
if (d[0] === 3 && sp.has(d[1])) { sp.get(d[1]).res(d[2]); sp.delete(d[1]); }
else if (d[0] === 2) { sock.write(encodeFrame(JSON.stringify([3, d[1], {}]))); }
} catch(e) {}
}
async function bootStation(station) {
// Retry connection up to 10 times with 3s delay
for (let attempt = 1; attempt <= 10; attempt++) {
try {
const { sock, sendCall } = await connect(station);
console.log('[' + station.id + '] Connected (attempt ' + attempt + ')');
const boot = await sendCall('BootNotification', { chargingStation: { model: station.id, vendorName: 'Cariflex', firmwareVersion: '1.0.0', serialNumber: station.id }, reason: 'PowerUp' });
console.log('[' + station.id + '] Boot: ' + boot.status);
await sendCall('StatusNotification', { evseId: 1, connectorId: 1, connectorStatus: 'Available', timestamp: new Date().toISOString() });
console.log('[' + station.id + '] Available');
return { sock, sendCall };
} catch(e) {
console.log('[' + station.id + '] Attempt ' + attempt + ' failed: ' + e.message);
if (attempt < 10) await new Promise(r => setTimeout(r, 3000));
}
}
throw new Error('Failed after 10 attempts');
}
async function main() {
const tenantId = '1';
console.log('Simulating ' + STATIONS.length + ' stations...');
const online = [];
for (const st of STATIONS) {
try { const s = await bootStation(st); online.push(Object.assign({}, st, s)); await new Promise(r => setTimeout(r, 300)); }
catch(e) { console.error('[' + st.id + '] ' + e.message); }
}
console.log('\n=== ' + online.length + '/' + STATIONS.length + ' ONLINE ===');
setInterval(async () => { for (const s of online) { try { await s.sendCall('Heartbeat', {}); } catch(e) {} } console.log('[HB] ' + online.length); }, 60000);
}
process.on('SIGINT', () => { console.log('exit'); process.exit(0); });
main().catch(e => { console.error(e); process.exit(1); });

281
scripts/ocpp-simulator.js Normal file
View File

@@ -0,0 +1,281 @@
#!/usr/bin/env node
/**
* OCPP 2.0.1 Charging Station Simulator for Cariflex/CitrineOS
* Uses raw TCP WebSocket (bypassing ws module subprotocol issues)
*/
const net = require('net');
const crypto = require('crypto');
const CONFIG = {
host: process.env.OCPP_HOST || 'localhost',
port: parseInt(process.env.OCPP_PORT || '8082'),
path: '/1/CP001',
stationId: 'CP001',
password: 'DEADBEEFDEADBEEF',
vendorName: 'Cariflex',
firmwareVersion: '1.0.0',
};
let msgId = 0;
const pending = new Map();
let socket = null;
let buffer = Buffer.alloc(0);
let wsReady = false;
function encodeFrame(payload) {
const maskKey = crypto.randomBytes(4);
const payloadBuf = Buffer.from(payload, 'utf8');
const len = payloadBuf.length;
let frameLen;
if (len < 126) {
frameLen = 6 + len; // 2 header + 4 mask + payload
} else if (len < 65536) {
frameLen = 8 + len; // 2 header + 2 ext len + 4 mask + payload
} else {
frameLen = 16 + len; // 2 header + 8 ext len + 4 mask + payload
}
const frame = Buffer.alloc(frameLen);
frame[0] = 0x81; // FIN + text
if (len < 126) {
frame[1] = 0x80 | len;
} else if (len < 65536) {
frame[1] = 0x80 | 126;
frame.writeUInt16BE(len, 2);
} else {
frame[1] = 0x80 | 127;
frame.writeBigUInt64BE(BigInt(len), 2);
}
maskKey.copy(frame, frameLen - len - 4);
for (let i = 0; i < len; i++) {
frame[frameLen - len + i] = payloadBuf[i] ^ maskKey[i % 4];
}
return frame;
}
function parseFrames() {
const messages = [];
while (buffer.length >= 2) {
const firstByte = buffer[0];
const secondByte = buffer[1];
const opcode = firstByte & 0x0F;
const masked = (secondByte & 0x80) !== 0;
let payloadLen = secondByte & 0x7F;
let headerLen = 2;
if (payloadLen === 126) {
if (buffer.length < 4) return messages;
payloadLen = buffer.readUInt16BE(2);
headerLen = 4;
} else if (payloadLen === 127) {
if (buffer.length < 10) return messages;
payloadLen = Number(buffer.readBigUInt64BE(2));
headerLen = 10;
}
if (masked) headerLen += 4;
const totalLen = headerLen + payloadLen;
if (buffer.length < totalLen) return messages;
let payload = buffer.slice(headerLen, headerLen + payloadLen);
if (masked) {
const maskKey = buffer.slice(headerLen - 4, headerLen);
const unmasked = Buffer.alloc(payloadLen);
for (let i = 0; i < payloadLen; i++) {
unmasked[i] = payload[i] ^ maskKey[i % 4];
}
payload = unmasked;
}
messages.push({ opcode, payload: payload.toString('utf8') });
buffer = buffer.slice(totalLen);
}
return messages;
}
function sendCall(action, payload) {
return new Promise((resolve, reject) => {
const id = String(++msgId);
const msg = JSON.stringify([2, id, action, payload]);
pending.set(id, { resolve, reject });
console.log(`[SEND] ${action} (${id})`);
socket.write(encodeFrame(msg));
setTimeout(() => {
if (pending.has(id)) {
pending.delete(id);
reject(new Error(`Timeout: ${action}`));
}
}, 15000);
});
}
function handleMessage(msg) {
try {
const data = JSON.parse(msg);
if (data[0] === 3) { // CallResult
const id = data[1];
if (pending.has(id)) {
pending.get(id).resolve(data[2]);
pending.delete(id);
}
} else if (data[0] === 2) { // Call from server
const id = data[1];
const action = data[2];
console.log(`[CALL] ${action} from server`);
const response = JSON.stringify([3, id, {}]);
socket.write(encodeFrame(response));
} else if (data[0] === 4) { // Error
const id = data[1];
console.log(`[ERROR] ${data[2]}: ${data[3]}`);
if (pending.has(id)) {
pending.get(id).reject(new Error(`${data[2]}: ${data[3]}`));
pending.delete(id);
}
}
} catch (e) {
console.error('[PARSE ERROR]', e.message, '- raw:', JSON.stringify(msg.substring(0, 100)));
}
}
function connect() {
return new Promise((resolve, reject) => {
console.log(`Connecting to ${CONFIG.host}:${CONFIG.port}${CONFIG.path}...`);
socket = net.createConnection(CONFIG.port, CONFIG.host, () => {
const key = crypto.randomBytes(16).toString('base64');
const encoded = Buffer.from(CONFIG.stationId + ':' + CONFIG.password).toString('base64');
const handshake = `GET ${CONFIG.path} HTTP/1.1\r\n` +
`Host: ${CONFIG.host}:${CONFIG.port}\r\n` +
`Upgrade: websocket\r\n` +
`Connection: Upgrade\r\n` +
`Sec-WebSocket-Key: ${key}\r\n` +
`Sec-WebSocket-Version: 13\r\n` +
`Sec-WebSocket-Protocol: ocpp2.0.1\r\n` +
`Authorization: Basic ${encoded}\r\n` +
`\r\n`;
socket.write(handshake);
});
socket.on('data', (data) => {
buffer = Buffer.concat([buffer, data]);
if (!wsReady) {
const str = buffer.toString('utf8');
const headerEnd = str.indexOf('\r\n\r\n');
if (headerEnd === -1) return; // Wait for full HTTP response
const statusLine = str.split('\r\n')[0];
console.log('Handshake:', statusLine);
if (statusLine.includes('101')) {
wsReady = true;
buffer = buffer.slice(headerEnd + 4); // Skip past HTTP headers
console.log('✅ WebSocket connected!');
resolve();
} else {
console.log('❌ Handshake failed');
console.log(str.substring(0, 500));
socket.end();
reject(new Error('Handshake failed'));
}
return;
}
// Parse WebSocket frames
const messages = parseFrames();
for (const msg of messages) {
if (msg.opcode === 0x1) { // Text frame
handleMessage(msg.payload);
} else if (msg.opcode === 0x8) { // Close frame
console.log('[CLOSE] Server sent close frame');
socket.end();
} else if (msg.opcode === 0x9) { // Ping
// Send pong
socket.write(encodeFrame(''));
}
}
});
socket.on('error', (e) => {
console.error('[SOCKET ERROR]', e.message);
reject(e);
});
socket.on('close', () => {
console.log('[DISCONNECTED]');
});
setTimeout(() => reject(new Error('Connection timeout')), 10000);
});
}
async function run() {
try {
await connect();
// BootNotification
console.log('Sending BootNotification...');
const bootResult = await sendCall('BootNotification', {
chargingStation: {
model: CONFIG.stationId,
vendorName: CONFIG.vendorName,
firmwareVersion: CONFIG.firmwareVersion,
serialNumber: CONFIG.stationId,
},
reason: 'PowerUp',
});
console.log('✅ BootNotification result:', bootResult);
// StatusNotification - Available
console.log('Sending StatusNotification (Available)...');
await sendCall('StatusNotification', {
evseId: 1,
connectorId: 1,
connectorStatus: 'Available',
timestamp: new Date().toISOString(),
});
console.log('✅ Connector 1 is Available');
// Heartbeat
console.log('Sending Heartbeat...');
const hbResult = await sendCall('Heartbeat', {});
console.log('✅ Heartbeat result:', hbResult);
console.log('');
console.log('=== ✅ Station CP001 is ONLINE and READY ===');
console.log('=== Press Ctrl+C to stop ===');
// Keep alive with heartbeats every 60s
setInterval(async () => {
try {
await sendCall('Heartbeat', {});
console.log('[HEARTBEAT] ✅');
} catch (e) {
console.error('[HEARTBEAT] ❌', e.message);
}
}, 60000);
} catch (e) {
console.error('❌ Failed:', e.message);
process.exit(1);
}
}
process.on('SIGINT', () => {
console.log('\nShutting down...');
if (socket) socket.end();
process.exit(0);
});
run();

View File

@@ -0,0 +1,133 @@
#!/usr/bin/env node
/**
* OCPP 2.0.1 Security Profile 0 connector for Cariflex/CitrineOS
* Connects via WebSocket to CSMS on port 8081 (no auth) to update ocppConnectionName
* The main simulator handles port 8082 (Security Profile 1, Basic Auth) for actual OCPP messages
*/
const net = require('net');
const crypto = require('crypto');
const WS_HOST = process.env.OCPP_HOST || 'cariflex-citrineos-server';
const WS_PORT = parseInt(process.env.OCPP_PORT || '8081');
function encodeFrame(payload) {
const maskKey = crypto.randomBytes(4);
const pb = Buffer.from(payload, 'utf8');
const len = pb.length;
const fl = len < 126 ? 6 + len : 8 + len;
const f = Buffer.alloc(fl);
f[0] = 0x81;
if (len < 126) { f[1] = 0x80 | len; }
else { f[1] = 0x80 | 126; f.writeUInt16BE(len, 2); }
maskKey.copy(f, fl - len - 4);
for (let i = 0; i < len; i++) f[fl - len + i] = pb[i] ^ maskKey[i % 4];
return f;
}
function parseFrames(buf) {
const msgs = [];
while (buf.length >= 2) {
const op = buf[0] & 0x0F;
const masked = (buf[1] & 0x80) !== 0;
let pl = buf[1] & 0x7F, hl = 2;
if (pl === 126) { if (buf.length < 4) return msgs; pl = buf.readUInt16BE(2); hl = 4; }
else if (pl === 127) { if (buf.length < 10) return msgs; pl = Number(buf.readBigUInt64BE(2)); hl = 10; }
if (masked) hl += 4;
const tl = hl + pl;
if (buf.length < tl) return msgs;
let payload = buf.slice(hl, hl + pl);
if (masked) {
const mk = buf.slice(hl - 4, hl);
const u = Buffer.alloc(pl);
for (let i = 0; i < pl; i++) u[i] = payload[i] ^ mk[i % 4];
payload = u;
}
msgs.push({ op, payload: payload.toString('utf8') });
buf = buf.slice(tl);
}
return msgs;
}
async function connectStation(stationId) {
return new Promise((resolve, reject) => {
const sock = net.createConnection(WS_PORT, WS_HOST, () => {
const key = crypto.randomBytes(16).toString('base64');
const handshake = `GET /${stationId} HTTP/1.1\r\nHost: ${WS_HOST}:${WS_PORT}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: ${key}\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Protocol: ocpp2.0.1\r\n\r\n`;
sock.write(handshake);
});
let buf = Buffer.alloc(0), ready = false, msgId = 0;
const pending = new Map();
sock.on('data', (data) => {
buf = Buffer.concat([buf, data]);
if (!ready) {
const s = buf.toString('utf8');
const he = s.indexOf('\r\n\r\n');
if (he === -1) return;
const sl = s.split('\r\n')[0];
if (sl.includes('101')) {
ready = true;
buf = buf.slice(he + 4);
// Send BootNotification
const id = String(++msgId);
const payload = JSON.stringify([2, id, 'BootNotification', JSON.stringify({ chargingStation: { model: stationId, vendorName: 'Cariflex', firmwareVersion: '1.0.0', serialNumber: stationId }, reason: 'PowerUp' })]);
sock.write(encodeFrame(payload));
pending.set(id, { resolve: () => {}, reject: () => {} });
} else {
sock.end();
reject(new Error('Handshake failed: ' + sl));
}
return;
}
const msgs = parseFrames(buf);
for (const m of msgs) {
if (m.op === 0x1) {
try {
const d = JSON.parse(m.payload);
if (d[0] === 3) {
console.log('[' + stationId + '] Boot: ' + d[2].status);
// Now send StatusNotification
const id2 = String(++msgId);
const sn = JSON.stringify([2, id2, 'StatusNotification', JSON.stringify({ evseId: 1, connectorId: 1, connectorStatus: 'Available', timestamp: new Date().toISOString() })]);
sock.write(encodeFrame(sn));
console.log('[' + stationId + '] StatusNotification sent');
}
// Respond to server calls
if (d[0] === 2) {
sock.write(encodeFrame(JSON.stringify([3, d[1], {}])));
}
} catch(e) {}
if (d[0] === 3 && d[2] && d[2].status === 'Accepted') {
resolve({ sock, pending });
}
} else if (m.op === 0x8) {
sock.end();
}
}
});
sock.on('error', (e) => { console.error('[' + stationId + '] ' + e.message); reject(e); });
sock.on('close', () => console.log('[' + stationId + '] DC'));
setTimeout(() => reject(new Error('timeout')), 10000);
});
}
async function main() {
const stations = Array.from({length: 15}, (_, i) => 'CP' + String(i+1).padStart(3, '0'));
console.log('Connecting ' + stations.length + ' stations to port ' + WS_PORT + ' (Security Profile 0)...');
const connections = [];
for (const st of stations) {
try {
const conn = await connectStation(st);
connections.push({ id: st, ...conn });
console.log('[' + st + '] Connected!');
await new Promise(r => setTimeout(r, 200));
} catch(e) {
console.error('[' + st + '] Failed: ' + e.message);
}
}
console.log('\n=== ' + connections.length + '/' + stations.length + ' connected on SP0 ===');
// Keep alive
setInterval(() => {}, 60000);
}
process.on('SIGINT', () => { console.log('exit'); process.exit(0); });
main().catch(e => { console.error(e); process.exit(1); });