evcc-io/tests/simulator/ocppClient.ts
2026-06-07 11:17:54 +00:00

258 lines
7.3 KiB
TypeScript

import WebSocket from "ws";
const RECONNECT_DELAY = 2000;
export class OcppClient {
private ws: WebSocket | null = null;
private messageId = 0;
private pendingCallbacks = new Map<string, (response: any) => void>();
private connected = false;
private stationId: string;
private serverUrl: string;
private shouldReconnect = false;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
constructor(stationId: string, serverUrl: string) {
this.stationId = stationId;
this.serverUrl = serverUrl;
}
// connect starts the connection loop and returns immediately. The client keeps
// re-dialing every RECONNECT_DELAY until disconnect() is called, so it survives
// an unreachable server or a dropped connection (e.g. evcc restart).
connect(): void {
this.shouldReconnect = true;
this.dial();
}
private dial(): void {
const url = `${this.serverUrl}${this.stationId}`;
console.log(`[OCPP Client] Connecting to ${url}`);
const ws = new WebSocket(url, ["ocpp1.6"]);
this.ws = ws;
// force a close if the handshake never completes, so the retry loop kicks in
const handshakeTimer = setTimeout(() => {
if (!this.connected) {
console.log(`[OCPP Client] ${this.stationId} handshake timeout`);
ws.terminate();
}
}, 5000);
ws.on("open", () => {
clearTimeout(handshakeTimer);
console.log(`[OCPP Client] Connected as ${this.stationId}`);
this.connected = true;
// boot, then report the connector available, like a real charger would
this.bootNotification()
.then(() => this.statusNotification(1, "Available"))
.catch((error) => console.error(`[OCPP Client] init failed:`, error));
});
ws.on("message", (data: WebSocket.Data) => {
const message = JSON.parse(data.toString());
this.handleMessage(message);
});
ws.on("error", (error) => {
console.error(`[OCPP Client] Error:`, (error as Error).message || error);
});
ws.on("close", () => {
clearTimeout(handshakeTimer);
this.connected = false;
if (this.ws === ws) this.ws = null;
if (this.shouldReconnect) {
console.log(
`[OCPP Client] ${this.stationId} disconnected, retrying in ${RECONNECT_DELAY}ms`
);
this.reconnectTimer = setTimeout(() => this.dial(), RECONNECT_DELAY);
}
});
}
private handleMessage(message: any[]) {
const messageType = message[0];
// Handle CallResult (3) - response to our calls
if (messageType === 3) {
const messageId = message[1];
const payload = message[2];
const callback = this.pendingCallbacks.get(messageId);
if (callback) {
callback(payload);
this.pendingCallbacks.delete(messageId);
}
}
// Handle Call (2) - requests from server
else if (messageType === 2) {
const messageId = message[1];
const action = message[2];
const payload = message[3];
console.log(`[OCPP Client] Received ${action}:`, payload);
// Auto-respond to common server requests
this.handleServerRequest(messageId, action, payload);
}
}
private handleServerRequest(messageId: string, action: string, payload: any) {
let response: any = {};
switch (action) {
case "GetConfiguration":
response = {
configurationKey: [
{ key: "NumberOfConnectors", readonly: true, value: "1" },
{
key: "MeterValuesSampledData",
readonly: false,
value: "Energy.Active.Import.Register,Power.Active.Import",
},
],
};
break;
case "ChangeConfiguration":
response = { status: "Accepted" };
break;
case "ChangeAvailability":
response = { status: "Accepted" };
break;
case "RemoteStartTransaction":
response = { status: "Accepted" };
break;
case "RemoteStopTransaction":
response = { status: "Accepted" };
break;
case "TriggerMessage":
// actually send the requested message so evcc's setup wait completes
switch (payload.requestedMessage) {
case "StatusNotification":
this.statusNotification(1, "Available");
break;
case "MeterValues":
this.meterValues(1, 0, 0);
break;
case "BootNotification":
this.bootNotification();
break;
}
response = { status: "Accepted" };
break;
default:
console.log(`[OCPP Client] Unknown action ${action}, sending empty response`);
}
// Send CallResult
const responseMessage = [3, messageId, response];
this.send(responseMessage);
}
private send(message: any): void {
if (this.ws && this.connected) {
const data = JSON.stringify(message);
console.log(`[OCPP Client] Sending:`, data);
this.ws.send(data);
}
}
private async call(action: string, payload: any): Promise<any> {
return new Promise((resolve, reject) => {
const messageId = (++this.messageId).toString();
const message = [2, messageId, action, payload];
// Setup callback for response
this.pendingCallbacks.set(messageId, resolve);
// Send message
this.send(message);
// Timeout after 10 seconds
setTimeout(() => {
if (this.pendingCallbacks.has(messageId)) {
this.pendingCallbacks.delete(messageId);
reject(new Error(`Timeout waiting for response to ${action}`));
}
}, 10000);
});
}
async bootNotification(model = "Simulator", vendor = "evcc-test"): Promise<any> {
return this.call("BootNotification", {
chargePointModel: model,
chargePointVendor: vendor,
chargePointSerialNumber: this.stationId,
firmwareVersion: "1.0.0",
});
}
async statusNotification(
connectorId: number,
status: string,
errorCode = "NoError"
): Promise<any> {
return this.call("StatusNotification", {
connectorId,
status,
errorCode,
timestamp: new Date().toISOString(),
});
}
async meterValues(connectorId: number, power: number, energy: number): Promise<any> {
return this.call("MeterValues", {
connectorId,
meterValue: [
{
timestamp: new Date().toISOString(),
sampledValue: [
{
value: power.toString(),
context: "Sample.Periodic",
format: "Raw",
measurand: "Power.Active.Import",
phase: null,
location: "Outlet",
unit: "W",
},
{
value: energy.toString(),
context: "Sample.Periodic",
format: "Raw",
measurand: "Energy.Active.Import.Register",
phase: null,
location: "Outlet",
unit: "Wh",
},
],
},
],
});
}
disconnect(): void {
this.shouldReconnect = false;
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
if (this.ws) {
this.ws.close();
this.ws = null;
}
this.connected = false;
}
isConnected(): boolean {
return this.connected;
}
getStationId(): string {
return this.stationId;
}
getServerUrl(): string {
return this.serverUrl;
}
}