From a70f5adf15d9470ad6888fc471252a0a11217f9b Mon Sep 17 00:00:00 2001 From: Eric F Date: Mon, 15 Jun 2026 16:40:27 -0400 Subject: [PATCH] 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 --- config/docker-compose-citrineos-everest.yml | 158 +++++++++++ config/docker-compose-citrineos.yml | 35 +++ scripts/Dockerfile.simulator | 5 + scripts/Dockerfile.sp0 | 5 + scripts/configure-auth.py | 29 ++ scripts/configure-auth.sh | 33 +++ scripts/ocpp-simulator-multi.js | 133 +++++++++ scripts/ocpp-simulator.js | 281 ++++++++++++++++++++ scripts/ocpp-sp0-connector.js | 133 +++++++++ 9 files changed, 812 insertions(+) create mode 100644 config/docker-compose-citrineos-everest.yml create mode 100644 scripts/Dockerfile.simulator create mode 100644 scripts/Dockerfile.sp0 create mode 100644 scripts/configure-auth.py create mode 100644 scripts/configure-auth.sh create mode 100644 scripts/ocpp-simulator-multi.js create mode 100644 scripts/ocpp-simulator.js create mode 100644 scripts/ocpp-sp0-connector.js diff --git a/config/docker-compose-citrineos-everest.yml b/config/docker-compose-citrineos-everest.yml new file mode 100644 index 0000000..b756b9d --- /dev/null +++ b/config/docker-compose-citrineos-everest.yml @@ -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 diff --git a/config/docker-compose-citrineos.yml b/config/docker-compose-citrineos.yml index d2aac30..5c45ff2 100644 --- a/config/docker-compose-citrineos.yml +++ b/config/docker-compose-citrineos.yml @@ -120,6 +120,41 @@ services: - traefik-public - 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: citrineos-data: driver: local diff --git a/scripts/Dockerfile.simulator b/scripts/Dockerfile.simulator new file mode 100644 index 0000000..5cdabc8 --- /dev/null +++ b/scripts/Dockerfile.simulator @@ -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"] diff --git a/scripts/Dockerfile.sp0 b/scripts/Dockerfile.sp0 new file mode 100644 index 0000000..1e08017 --- /dev/null +++ b/scripts/Dockerfile.sp0 @@ -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"] diff --git a/scripts/configure-auth.py b/scripts/configure-auth.py new file mode 100644 index 0000000..56df1a0 --- /dev/null +++ b/scripts/configure-auth.py @@ -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") diff --git a/scripts/configure-auth.sh b/scripts/configure-auth.sh new file mode 100644 index 0000000..162d31f --- /dev/null +++ b/scripts/configure-auth.sh @@ -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 ===" diff --git a/scripts/ocpp-simulator-multi.js b/scripts/ocpp-simulator-multi.js new file mode 100644 index 0000000..e6e7c85 --- /dev/null +++ b/scripts/ocpp-simulator-multi.js @@ -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); }); diff --git a/scripts/ocpp-simulator.js b/scripts/ocpp-simulator.js new file mode 100644 index 0000000..dd4a19c --- /dev/null +++ b/scripts/ocpp-simulator.js @@ -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(); diff --git a/scripts/ocpp-sp0-connector.js b/scripts/ocpp-sp0-connector.js new file mode 100644 index 0000000..5b208dc --- /dev/null +++ b/scripts/ocpp-sp0-connector.js @@ -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); });