- 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
282 lines
7.8 KiB
JavaScript
282 lines
7.8 KiB
JavaScript
#!/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();
|