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