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

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();