#!/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); });