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

128 lines
4.6 KiB
TypeScript

import { WebSocketServer, WebSocket } from "ws";
import type { IncomingMessage } from "http";
import type { Duplex } from "stream";
// minimal surface we need from the shared http(s)/http2 server
type UpgradableServer = {
on(
event: "upgrade",
listener: (req: IncomingMessage, socket: Duplex, head: Buffer) => void
): unknown;
};
// OcppServer is a minimal upstream OCPP server used to test the evcc forwarder.
// It is NOT spec compliant: it accepts a WebSocket connection (optionally behind
// HTTP Basic Auth) and answers Calls with canned CallResults so the connection
// stays alive. It shares the simulator's HTTP port via the vite httpServer.
class OcppServer {
// echo the ocpp1.6 subprotocol so clients that require negotiation (evcc's
// coder/websocket dialer) complete the handshake
private wss = new WebSocketServer({ noServer: true, handleProtocols: () => "ocpp1.6" });
private sockets = new Set<WebSocket>();
enabled = false;
username = "";
password = "";
lastStationId: string | null = null;
// attach hooks the WebSocket upgrade on the shared http server. OCPP upgrades
// are recognised by the "ocpp1.6" subprotocol; everything else (e.g. vite HMR)
// is left for other listeners.
attach(httpServer: UpgradableServer) {
httpServer.on("upgrade", (req: IncomingMessage, socket: Duplex, head: Buffer) => {
const protocols = String(req.headers["sec-websocket-protocol"] || "");
if (!protocols.includes("ocpp1.6")) return; // not an OCPP upgrade (e.g. vite HMR)
// server off: reject the OCPP upgrade so the client errors out and retries,
// rather than leaving the handshake hanging with no response
if (!this.enabled) {
socket.destroy();
return;
}
if ((this.username || this.password) && !this.checkAuth(req)) {
console.log("[ocpp-server] rejected: bad credentials");
socket.write('HTTP/1.1 401 Unauthorized\r\nWWW-Authenticate: Basic realm="ocpp"\r\n\r\n');
socket.destroy();
return;
}
this.wss.handleUpgrade(req, socket, head, (ws) => this.onConnection(ws, req));
});
}
configure(opts: { enabled: boolean; username?: string; password?: string }) {
this.enabled = opts.enabled;
this.username = opts.username || "";
this.password = opts.password || "";
if (!this.enabled) this.closeAll();
}
status() {
return {
enabled: this.enabled,
username: this.username,
password: this.password,
lastStationId: this.lastStationId,
connections: this.sockets.size,
};
}
private checkAuth(req: IncomingMessage): boolean {
const header = String(req.headers["authorization"] || "");
const expected = "Basic " + Buffer.from(`${this.username}:${this.password}`).toString("base64");
return header === expected;
}
private onConnection(ws: WebSocket, req: IncomingMessage) {
const path = (req.url || "/").split("?")[0];
const stationId = decodeURIComponent(path.replace(/^\/+/, "")) || "(root)";
this.lastStationId = stationId;
this.sockets.add(ws);
console.log(`[ocpp-server] ${stationId} connected (${this.sockets.size} active)`);
ws.on("message", (data) => this.handleMessage(ws, data.toString()));
ws.on("close", () => {
this.sockets.delete(ws);
console.log(`[ocpp-server] ${stationId} disconnected (${this.sockets.size} active)`);
});
}
// answer charger Calls (messageType 2) with a CallResult (messageType 3)
private handleMessage(ws: WebSocket, raw: string) {
let msg: unknown;
try {
msg = JSON.parse(raw);
} catch {
return;
}
if (!Array.isArray(msg) || msg[0] !== 2) return; // only respond to Calls
const [, messageId, action] = msg as [number, string, string, unknown];
ws.send(JSON.stringify([3, messageId, this.resultFor(action)]));
}
private resultFor(action: string): Record<string, unknown> {
const now = new Date().toISOString();
switch (action) {
case "BootNotification":
return { status: "Accepted", currentTime: now, interval: 300 };
case "Heartbeat":
return { currentTime: now };
case "Authorize":
return { idTagInfo: { status: "Accepted" } };
case "StartTransaction":
return { transactionId: 1, idTagInfo: { status: "Accepted" } };
case "StopTransaction":
return { idTagInfo: { status: "Accepted" } };
default:
return {}; // StatusNotification, MeterValues, DataTransfer, ...
}
}
private closeAll() {
for (const ws of this.sockets) ws.close();
this.sockets.clear();
}
}
export const ocppServer = new OcppServer();