ocpp-ws-io 1.0.0-alpha
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of ocpp-ws-io might be problematic. Click here for more details.
- package/.github/workflows/publish.yml +52 -0
- package/LICENSE +21 -0
- package/README.md +773 -0
- package/dist/adapters/redis.d.mts +73 -0
- package/dist/adapters/redis.d.ts +73 -0
- package/dist/adapters/redis.js +96 -0
- package/dist/adapters/redis.js.map +1 -0
- package/dist/adapters/redis.mjs +71 -0
- package/dist/adapters/redis.mjs.map +1 -0
- package/dist/index.d.mts +268 -0
- package/dist/index.d.ts +268 -0
- package/dist/index.js +38919 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +38855 -0
- package/dist/index.mjs.map +1 -0
- package/dist/types-6LVUoXof.d.mts +284 -0
- package/dist/types-6LVUoXof.d.ts +284 -0
- package/package.json +59 -0
- package/src/adapters/adapter.ts +40 -0
- package/src/adapters/redis.ts +144 -0
- package/src/client.ts +882 -0
- package/src/errors.ts +183 -0
- package/src/event-buffer.ts +73 -0
- package/src/index.ts +68 -0
- package/src/queue.ts +65 -0
- package/src/schemas/ocpp1_6.json +2376 -0
- package/src/schemas/ocpp2_0_1.json +11878 -0
- package/src/schemas/ocpp2_1.json +23176 -0
- package/src/server-client.ts +65 -0
- package/src/server.ts +374 -0
- package/src/standard-validators.ts +18 -0
- package/src/types.ts +316 -0
- package/src/util.ts +119 -0
- package/src/validator.ts +148 -0
- package/src/ws-util.ts +186 -0
- package/test/adapter.test.ts +88 -0
- package/test/client.test.ts +297 -0
- package/test/errors.test.ts +132 -0
- package/test/queue.test.ts +133 -0
- package/test/server.test.ts +274 -0
- package/test/util.test.ts +103 -0
- package/test/ws-util.test.ts +93 -0
- package/tsconfig.json +25 -0
- package/tsup.config.ts +16 -0
- package/vitest.config.ts +10 -0
package/src/client.ts
ADDED
|
@@ -0,0 +1,882 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { EventEmitter } from "node:events";
|
|
3
|
+
import { setTimeout as setTimeoutCb } from "node:timers";
|
|
4
|
+
import WebSocket from "ws";
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
ConnectionState,
|
|
8
|
+
SecurityProfile,
|
|
9
|
+
MessageType,
|
|
10
|
+
NOREPLY,
|
|
11
|
+
type ClientOptions,
|
|
12
|
+
type CloseOptions,
|
|
13
|
+
type CallOptions,
|
|
14
|
+
type CallHandler,
|
|
15
|
+
type WildcardHandler,
|
|
16
|
+
type HandlerContext,
|
|
17
|
+
type OCPPCall,
|
|
18
|
+
type OCPPCallResult,
|
|
19
|
+
type OCPPCallError,
|
|
20
|
+
type OCPPMessage,
|
|
21
|
+
} from "./types.js";
|
|
22
|
+
import {
|
|
23
|
+
TimeoutError,
|
|
24
|
+
UnexpectedHttpResponse,
|
|
25
|
+
RPCGenericError,
|
|
26
|
+
RPCMessageTypeNotSupportedError,
|
|
27
|
+
type RPCError,
|
|
28
|
+
} from "./errors.js";
|
|
29
|
+
import {
|
|
30
|
+
createRPCError,
|
|
31
|
+
getErrorPlainObject,
|
|
32
|
+
getPackageIdent,
|
|
33
|
+
} from "./util.js";
|
|
34
|
+
import { Queue } from "./queue.js";
|
|
35
|
+
import type { Validator } from "./validator.js";
|
|
36
|
+
import { standardValidators } from "./standard-validators.js";
|
|
37
|
+
|
|
38
|
+
const { CONNECTING, OPEN, CLOSING, CLOSED } = ConnectionState;
|
|
39
|
+
|
|
40
|
+
interface PendingCall {
|
|
41
|
+
resolve: (value: unknown) => void;
|
|
42
|
+
reject: (reason: unknown) => void;
|
|
43
|
+
timeoutHandle: ReturnType<typeof setTimeoutCb>;
|
|
44
|
+
abortHandler?: () => void;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* OCPPClient — A typed WebSocket RPC client for OCPP communication.
|
|
49
|
+
*
|
|
50
|
+
* Supports all 3 OCPP Security Profiles:
|
|
51
|
+
* - Profile 1: Basic Auth over unsecured WS
|
|
52
|
+
* - Profile 2: TLS + Basic Auth
|
|
53
|
+
* - Profile 3: Mutual TLS (client certificates)
|
|
54
|
+
*/
|
|
55
|
+
export class OCPPClient extends EventEmitter {
|
|
56
|
+
// Static connection states
|
|
57
|
+
static readonly CONNECTING = CONNECTING;
|
|
58
|
+
static readonly OPEN = OPEN;
|
|
59
|
+
static readonly CLOSING = CLOSING;
|
|
60
|
+
static readonly CLOSED = CLOSED;
|
|
61
|
+
|
|
62
|
+
protected _options: Required<
|
|
63
|
+
Pick<
|
|
64
|
+
ClientOptions,
|
|
65
|
+
| "identity"
|
|
66
|
+
| "endpoint"
|
|
67
|
+
| "callTimeoutMs"
|
|
68
|
+
| "pingIntervalMs"
|
|
69
|
+
| "deferPingsOnActivity"
|
|
70
|
+
| "callConcurrency"
|
|
71
|
+
| "maxBadMessages"
|
|
72
|
+
| "respondWithDetailedErrors"
|
|
73
|
+
| "reconnect"
|
|
74
|
+
| "maxReconnects"
|
|
75
|
+
| "backoffMin"
|
|
76
|
+
| "backoffMax"
|
|
77
|
+
>
|
|
78
|
+
> &
|
|
79
|
+
ClientOptions;
|
|
80
|
+
|
|
81
|
+
protected _state: ConnectionState = CLOSED;
|
|
82
|
+
protected _ws: WebSocket | null = null;
|
|
83
|
+
protected _protocol: string | undefined;
|
|
84
|
+
protected _identity: string;
|
|
85
|
+
|
|
86
|
+
private _handlers = new Map<string, CallHandler>();
|
|
87
|
+
private _wildcardHandler: WildcardHandler | null = null;
|
|
88
|
+
private _pendingCalls = new Map<string, PendingCall>();
|
|
89
|
+
private _pendingResponses = new Set<string>();
|
|
90
|
+
private _callQueue: Queue;
|
|
91
|
+
private _pingTimer: ReturnType<typeof setTimeoutCb> | null = null;
|
|
92
|
+
private _closePromise: Promise<{ code: number; reason: string }> | null =
|
|
93
|
+
null;
|
|
94
|
+
private _reconnectAttempt = 0;
|
|
95
|
+
private _reconnectTimer: ReturnType<typeof setTimeoutCb> | null = null;
|
|
96
|
+
private _badMessageCount = 0;
|
|
97
|
+
private _lastActivity = 0;
|
|
98
|
+
private _validators: Validator[] = [];
|
|
99
|
+
private _strictProtocols: string[] | null = null;
|
|
100
|
+
protected _handshake: unknown = null;
|
|
101
|
+
|
|
102
|
+
constructor(options: ClientOptions) {
|
|
103
|
+
super();
|
|
104
|
+
|
|
105
|
+
if (!options.identity) {
|
|
106
|
+
throw new Error("identity is required");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
this._identity = options.identity;
|
|
110
|
+
|
|
111
|
+
this._options = {
|
|
112
|
+
reconnect: true,
|
|
113
|
+
maxReconnects: Infinity,
|
|
114
|
+
backoffMin: 1000,
|
|
115
|
+
backoffMax: 30000,
|
|
116
|
+
callTimeoutMs: 30000,
|
|
117
|
+
pingIntervalMs: 30000,
|
|
118
|
+
deferPingsOnActivity: false,
|
|
119
|
+
callConcurrency: 1,
|
|
120
|
+
maxBadMessages: Infinity,
|
|
121
|
+
respondWithDetailedErrors: false,
|
|
122
|
+
securityProfile: SecurityProfile.NONE,
|
|
123
|
+
...options,
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
this._callQueue = new Queue(this._options.callConcurrency);
|
|
127
|
+
|
|
128
|
+
// Set up strict mode validators
|
|
129
|
+
if (this._options.strictMode) {
|
|
130
|
+
this._setupValidators();
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ─── Getters ─────────────────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
get identity(): string {
|
|
137
|
+
return this._identity;
|
|
138
|
+
}
|
|
139
|
+
get protocol(): string | undefined {
|
|
140
|
+
return this._protocol;
|
|
141
|
+
}
|
|
142
|
+
get state(): ConnectionState {
|
|
143
|
+
return this._state;
|
|
144
|
+
}
|
|
145
|
+
get securityProfile(): SecurityProfile {
|
|
146
|
+
return this._options.securityProfile ?? SecurityProfile.NONE;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ─── Connect ─────────────────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
async connect(): Promise<{ response: import("node:http").IncomingMessage }> {
|
|
152
|
+
if (this._state !== CLOSED) {
|
|
153
|
+
throw new Error(`Cannot connect: client is in state ${this._state}`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
this._state = CONNECTING;
|
|
157
|
+
this._reconnectAttempt = 0;
|
|
158
|
+
|
|
159
|
+
return this._connectInternal();
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
private async _connectInternal(): Promise<{
|
|
163
|
+
response: import("node:http").IncomingMessage;
|
|
164
|
+
}> {
|
|
165
|
+
return new Promise((resolve, reject) => {
|
|
166
|
+
const endpoint = this._buildEndpoint();
|
|
167
|
+
const wsOptions = this._buildWsOptions();
|
|
168
|
+
|
|
169
|
+
this.emit("connecting", { url: endpoint });
|
|
170
|
+
|
|
171
|
+
const ws = new WebSocket(
|
|
172
|
+
endpoint,
|
|
173
|
+
this._options.protocols ?? [],
|
|
174
|
+
wsOptions,
|
|
175
|
+
);
|
|
176
|
+
this._ws = ws;
|
|
177
|
+
|
|
178
|
+
const onOpen = () => {
|
|
179
|
+
cleanup();
|
|
180
|
+
this._state = OPEN;
|
|
181
|
+
this._protocol = ws.protocol;
|
|
182
|
+
this._badMessageCount = 0;
|
|
183
|
+
this._attachWebsocket(ws);
|
|
184
|
+
this._startPing();
|
|
185
|
+
|
|
186
|
+
// Create a minimal response object
|
|
187
|
+
const response = (
|
|
188
|
+
ws as unknown as {
|
|
189
|
+
_req?: { res?: import("node:http").IncomingMessage };
|
|
190
|
+
}
|
|
191
|
+
)._req?.res;
|
|
192
|
+
const result = {
|
|
193
|
+
response: response as import("node:http").IncomingMessage,
|
|
194
|
+
};
|
|
195
|
+
this.emit("open", result);
|
|
196
|
+
resolve(result);
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
const onError = (err: Error) => {
|
|
200
|
+
cleanup();
|
|
201
|
+
this._state = CLOSED;
|
|
202
|
+
this.emit("error", err);
|
|
203
|
+
reject(err);
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
const onUnexpectedResponse = (
|
|
207
|
+
_req: import("node:http").ClientRequest,
|
|
208
|
+
res: import("node:http").IncomingMessage,
|
|
209
|
+
) => {
|
|
210
|
+
cleanup();
|
|
211
|
+
this._state = CLOSED;
|
|
212
|
+
const err = new UnexpectedHttpResponse(
|
|
213
|
+
`Unexpected HTTP response: ${res.statusCode}`,
|
|
214
|
+
res.statusCode ?? 0,
|
|
215
|
+
res.headers as Record<string, string>,
|
|
216
|
+
);
|
|
217
|
+
this.emit("error", err);
|
|
218
|
+
reject(err);
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
const cleanup = () => {
|
|
222
|
+
ws.removeListener("open", onOpen);
|
|
223
|
+
ws.removeListener("error", onError);
|
|
224
|
+
ws.removeListener("unexpected-response", onUnexpectedResponse);
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
ws.on("open", onOpen);
|
|
228
|
+
ws.on("error", onError);
|
|
229
|
+
ws.on("unexpected-response", onUnexpectedResponse);
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ─── Close ───────────────────────────────────────────────────
|
|
234
|
+
|
|
235
|
+
async close(
|
|
236
|
+
options: CloseOptions = {},
|
|
237
|
+
): Promise<{ code: number; reason: string }> {
|
|
238
|
+
const {
|
|
239
|
+
code = 1000,
|
|
240
|
+
reason = "",
|
|
241
|
+
awaitPending = true,
|
|
242
|
+
force = false,
|
|
243
|
+
} = options;
|
|
244
|
+
|
|
245
|
+
if (this._closePromise) return this._closePromise;
|
|
246
|
+
|
|
247
|
+
if (this._state === CLOSED) {
|
|
248
|
+
return { code: 1000, reason: "" };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Cancel reconnection
|
|
252
|
+
if (this._reconnectTimer) {
|
|
253
|
+
clearTimeout(this._reconnectTimer);
|
|
254
|
+
this._reconnectTimer = null;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
this._closePromise = this._closeInternal(code, reason, awaitPending, force);
|
|
258
|
+
return this._closePromise;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
private async _closeInternal(
|
|
262
|
+
code: number,
|
|
263
|
+
reason: string,
|
|
264
|
+
awaitPending: boolean,
|
|
265
|
+
force: boolean,
|
|
266
|
+
): Promise<{ code: number; reason: string }> {
|
|
267
|
+
this._state = CLOSING;
|
|
268
|
+
this._stopPing();
|
|
269
|
+
|
|
270
|
+
if (!force && awaitPending) {
|
|
271
|
+
// Wait for pending calls to resolve
|
|
272
|
+
const pendingPromises = Array.from(this._pendingCalls.values()).map(
|
|
273
|
+
(p) =>
|
|
274
|
+
new Promise<void>((resolve) => {
|
|
275
|
+
const origResolve = p.resolve;
|
|
276
|
+
const origReject = p.reject;
|
|
277
|
+
p.resolve = (v: unknown) => {
|
|
278
|
+
origResolve(v);
|
|
279
|
+
resolve();
|
|
280
|
+
};
|
|
281
|
+
p.reject = (r: unknown) => {
|
|
282
|
+
origReject(r);
|
|
283
|
+
resolve();
|
|
284
|
+
};
|
|
285
|
+
}),
|
|
286
|
+
);
|
|
287
|
+
if (pendingPromises.length > 0) {
|
|
288
|
+
await Promise.allSettled(pendingPromises);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
return new Promise<{ code: number; reason: string }>((resolve) => {
|
|
293
|
+
if (!this._ws || this._ws.readyState === WebSocket.CLOSED) {
|
|
294
|
+
this._state = CLOSED;
|
|
295
|
+
this._cleanup();
|
|
296
|
+
const result = { code, reason };
|
|
297
|
+
this.emit("close", result);
|
|
298
|
+
resolve(result);
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const onClose = (closeCode: number, closeReason: Buffer) => {
|
|
303
|
+
this._ws?.removeListener("close", onClose);
|
|
304
|
+
this._state = CLOSED;
|
|
305
|
+
this._cleanup();
|
|
306
|
+
const result = { code: closeCode, reason: closeReason.toString() };
|
|
307
|
+
this.emit("close", result);
|
|
308
|
+
resolve(result);
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
this._ws.on("close", onClose);
|
|
312
|
+
|
|
313
|
+
if (force) {
|
|
314
|
+
this._ws.terminate();
|
|
315
|
+
} else {
|
|
316
|
+
this._ws.close(code, reason);
|
|
317
|
+
}
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// ─── Handle ──────────────────────────────────────────────────
|
|
322
|
+
|
|
323
|
+
handle<TParams = unknown, TResult = unknown>(
|
|
324
|
+
methodOrHandler: string | WildcardHandler,
|
|
325
|
+
handler?: CallHandler<TParams, TResult>,
|
|
326
|
+
): void {
|
|
327
|
+
if (typeof methodOrHandler === "function") {
|
|
328
|
+
this._wildcardHandler = methodOrHandler;
|
|
329
|
+
} else if (typeof methodOrHandler === "string" && handler) {
|
|
330
|
+
this._handlers.set(methodOrHandler, handler as CallHandler);
|
|
331
|
+
} else {
|
|
332
|
+
throw new Error(
|
|
333
|
+
"Invalid arguments: provide (method, handler) or (wildcardHandler)",
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
removeHandler(method?: string): void {
|
|
339
|
+
if (method) {
|
|
340
|
+
this._handlers.delete(method);
|
|
341
|
+
} else {
|
|
342
|
+
this._wildcardHandler = null;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
removeAllHandlers(): void {
|
|
347
|
+
this._handlers.clear();
|
|
348
|
+
this._wildcardHandler = null;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// ─── Call ────────────────────────────────────────────────────
|
|
352
|
+
|
|
353
|
+
async call<TResult = unknown>(
|
|
354
|
+
method: string,
|
|
355
|
+
params: unknown = {},
|
|
356
|
+
options: CallOptions = {},
|
|
357
|
+
): Promise<TResult> {
|
|
358
|
+
if (this._state !== OPEN) {
|
|
359
|
+
throw new Error(`Cannot call: client is in state ${this._state}`);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
return this._callQueue.push(() =>
|
|
363
|
+
this._sendCall<TResult>(method, params, options),
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
private async _sendCall<TResult>(
|
|
368
|
+
method: string,
|
|
369
|
+
params: unknown,
|
|
370
|
+
options: CallOptions,
|
|
371
|
+
): Promise<TResult> {
|
|
372
|
+
const msgId = randomUUID();
|
|
373
|
+
const timeoutMs = options.timeoutMs ?? this._options.callTimeoutMs;
|
|
374
|
+
|
|
375
|
+
// Strict mode: validate outbound call
|
|
376
|
+
if (this._options.strictMode && this._protocol) {
|
|
377
|
+
this._validateOutbound(method, params, "req");
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const message: OCPPCall = [MessageType.CALL, msgId, method, params];
|
|
381
|
+
const messageStr = JSON.stringify(message);
|
|
382
|
+
|
|
383
|
+
return new Promise<TResult>((resolve, reject) => {
|
|
384
|
+
const timeoutHandle = setTimeoutCb(() => {
|
|
385
|
+
this._pendingCalls.delete(msgId);
|
|
386
|
+
reject(
|
|
387
|
+
new TimeoutError(
|
|
388
|
+
`Call to "${method}" timed out after ${timeoutMs}ms`,
|
|
389
|
+
),
|
|
390
|
+
);
|
|
391
|
+
}, timeoutMs);
|
|
392
|
+
|
|
393
|
+
const pending: PendingCall = {
|
|
394
|
+
resolve: resolve as (v: unknown) => void,
|
|
395
|
+
reject,
|
|
396
|
+
timeoutHandle,
|
|
397
|
+
};
|
|
398
|
+
|
|
399
|
+
// Abort signal support
|
|
400
|
+
if (options.signal) {
|
|
401
|
+
if (options.signal.aborted) {
|
|
402
|
+
clearTimeout(timeoutHandle);
|
|
403
|
+
reject(options.signal.reason ?? new Error("Aborted"));
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
const abortHandler = () => {
|
|
407
|
+
clearTimeout(timeoutHandle);
|
|
408
|
+
this._pendingCalls.delete(msgId);
|
|
409
|
+
reject(options.signal!.reason ?? new Error("Aborted"));
|
|
410
|
+
};
|
|
411
|
+
options.signal.addEventListener("abort", abortHandler, { once: true });
|
|
412
|
+
pending.abortHandler = abortHandler;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
this._pendingCalls.set(msgId, pending);
|
|
416
|
+
this._ws!.send(messageStr);
|
|
417
|
+
this.emit("message", message);
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Send a raw string message over the WebSocket (use with caution).
|
|
423
|
+
*/
|
|
424
|
+
sendRaw(message: string): void {
|
|
425
|
+
if (this._state !== OPEN || !this._ws) {
|
|
426
|
+
throw new Error("Cannot send: client is not connected");
|
|
427
|
+
}
|
|
428
|
+
this._ws.send(message);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// ─── Reconfigure ─────────────────────────────────────────────
|
|
432
|
+
|
|
433
|
+
reconfigure(options: Partial<ClientOptions>): void {
|
|
434
|
+
Object.assign(this._options, options);
|
|
435
|
+
|
|
436
|
+
if (options.callConcurrency !== undefined) {
|
|
437
|
+
this._callQueue.setConcurrency(options.callConcurrency);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
if (
|
|
441
|
+
options.strictMode !== undefined ||
|
|
442
|
+
options.strictModeValidators !== undefined
|
|
443
|
+
) {
|
|
444
|
+
this._setupValidators();
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
if (options.pingIntervalMs !== undefined) {
|
|
448
|
+
this._stopPing();
|
|
449
|
+
if (this._state === OPEN) {
|
|
450
|
+
this._startPing();
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// ─── Internal: WebSocket attachment ──────────────────────────
|
|
456
|
+
|
|
457
|
+
protected _attachWebsocket(ws: WebSocket): void {
|
|
458
|
+
ws.on("message", (data: WebSocket.RawData) => this._onMessage(data));
|
|
459
|
+
ws.on("close", (code: number, reason: Buffer) =>
|
|
460
|
+
this._onClose(code, reason),
|
|
461
|
+
);
|
|
462
|
+
ws.on("error", (err: Error) => this.emit("error", err));
|
|
463
|
+
ws.on("ping", () => {
|
|
464
|
+
this._recordActivity();
|
|
465
|
+
this.emit("ping");
|
|
466
|
+
});
|
|
467
|
+
ws.on("pong", () => {
|
|
468
|
+
this._recordActivity();
|
|
469
|
+
this.emit("pong");
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// ─── Internal: Message handling ──────────────────────────────
|
|
474
|
+
|
|
475
|
+
private _onMessage(rawData: WebSocket.RawData): void {
|
|
476
|
+
this._recordActivity();
|
|
477
|
+
|
|
478
|
+
let message: OCPPMessage;
|
|
479
|
+
try {
|
|
480
|
+
const str = rawData.toString();
|
|
481
|
+
message = JSON.parse(str) as OCPPMessage;
|
|
482
|
+
if (!Array.isArray(message)) throw new Error("Message is not an array");
|
|
483
|
+
} catch (err) {
|
|
484
|
+
this._onBadMessage(rawData.toString(), err as Error);
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
const messageType = message[0];
|
|
489
|
+
|
|
490
|
+
switch (messageType) {
|
|
491
|
+
case MessageType.CALL:
|
|
492
|
+
this._handleIncomingCall(message as OCPPCall);
|
|
493
|
+
break;
|
|
494
|
+
case MessageType.CALLRESULT:
|
|
495
|
+
this._handleCallResult(message as OCPPCallResult);
|
|
496
|
+
break;
|
|
497
|
+
case MessageType.CALLERROR:
|
|
498
|
+
this._handleCallError(message as OCPPCallError);
|
|
499
|
+
break;
|
|
500
|
+
default:
|
|
501
|
+
this._onBadMessage(
|
|
502
|
+
JSON.stringify(message),
|
|
503
|
+
new RPCMessageTypeNotSupportedError(
|
|
504
|
+
`Unknown message type: ${messageType}`,
|
|
505
|
+
),
|
|
506
|
+
);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
private async _handleIncomingCall(message: OCPPCall): Promise<void> {
|
|
511
|
+
const [, msgId, method, params] = message;
|
|
512
|
+
|
|
513
|
+
this.emit("call", message);
|
|
514
|
+
|
|
515
|
+
if (this._state !== OPEN) {
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
try {
|
|
520
|
+
if (this._pendingResponses.has(msgId)) {
|
|
521
|
+
throw createRPCError(
|
|
522
|
+
"RpcFrameworkError",
|
|
523
|
+
`Already processing call with ID: ${msgId}`,
|
|
524
|
+
);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
let handler = this._handlers.get(method);
|
|
528
|
+
let isWildcard = false;
|
|
529
|
+
if (!handler) {
|
|
530
|
+
if (this._wildcardHandler) {
|
|
531
|
+
isWildcard = true;
|
|
532
|
+
} else {
|
|
533
|
+
throw createRPCError(
|
|
534
|
+
"NotImplemented",
|
|
535
|
+
`No handler for method: ${method}`,
|
|
536
|
+
);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// Strict mode: validate inbound call params
|
|
541
|
+
if (this._options.strictMode && this._protocol) {
|
|
542
|
+
this._validateInbound(method, params, "req");
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
this._pendingResponses.add(msgId);
|
|
546
|
+
|
|
547
|
+
const ac = new AbortController();
|
|
548
|
+
const context: HandlerContext = {
|
|
549
|
+
messageId: msgId,
|
|
550
|
+
method,
|
|
551
|
+
params,
|
|
552
|
+
signal: ac.signal,
|
|
553
|
+
};
|
|
554
|
+
|
|
555
|
+
let result: unknown;
|
|
556
|
+
if (isWildcard && this._wildcardHandler) {
|
|
557
|
+
result = await this._wildcardHandler(method, context);
|
|
558
|
+
} else {
|
|
559
|
+
result = await handler!(context);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
this._pendingResponses.delete(msgId);
|
|
563
|
+
|
|
564
|
+
if (result === NOREPLY) return;
|
|
565
|
+
|
|
566
|
+
// Strict mode: validate outbound response
|
|
567
|
+
if (this._options.strictMode && this._protocol) {
|
|
568
|
+
this._validateOutbound(method, result, "conf");
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
const response: OCPPCallResult = [MessageType.CALLRESULT, msgId, result];
|
|
572
|
+
this._ws?.send(JSON.stringify(response));
|
|
573
|
+
this.emit("callResult", response);
|
|
574
|
+
} catch (err) {
|
|
575
|
+
this._pendingResponses.delete(msgId);
|
|
576
|
+
|
|
577
|
+
const rpcErr =
|
|
578
|
+
err instanceof RPCGenericError || (err as RPCError).rpcErrorCode
|
|
579
|
+
? (err as RPCError)
|
|
580
|
+
: createRPCError("InternalError", (err as Error).message);
|
|
581
|
+
|
|
582
|
+
const details = this._options.respondWithDetailedErrors
|
|
583
|
+
? getErrorPlainObject(err as Error)
|
|
584
|
+
: {};
|
|
585
|
+
|
|
586
|
+
const errorResponse: OCPPCallError = [
|
|
587
|
+
MessageType.CALLERROR,
|
|
588
|
+
msgId,
|
|
589
|
+
rpcErr.rpcErrorCode,
|
|
590
|
+
rpcErr.rpcErrorMessage || (err as Error).message || "",
|
|
591
|
+
details,
|
|
592
|
+
];
|
|
593
|
+
this._ws?.send(JSON.stringify(errorResponse));
|
|
594
|
+
this.emit("callError", errorResponse);
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
private _handleCallResult(message: OCPPCallResult): void {
|
|
599
|
+
const [, msgId, result] = message;
|
|
600
|
+
|
|
601
|
+
this.emit("callResult", message);
|
|
602
|
+
|
|
603
|
+
const pending = this._pendingCalls.get(msgId);
|
|
604
|
+
if (!pending) return;
|
|
605
|
+
|
|
606
|
+
clearTimeout(pending.timeoutHandle);
|
|
607
|
+
if (pending.abortHandler) {
|
|
608
|
+
// Remove abort listener
|
|
609
|
+
}
|
|
610
|
+
this._pendingCalls.delete(msgId);
|
|
611
|
+
pending.resolve(result);
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
private _handleCallError(message: OCPPCallError): void {
|
|
615
|
+
const [, msgId, errorCode, errorMessage, errorDetails] = message;
|
|
616
|
+
|
|
617
|
+
this.emit("callError", message);
|
|
618
|
+
|
|
619
|
+
const pending = this._pendingCalls.get(msgId);
|
|
620
|
+
if (!pending) return;
|
|
621
|
+
|
|
622
|
+
clearTimeout(pending.timeoutHandle);
|
|
623
|
+
this._pendingCalls.delete(msgId);
|
|
624
|
+
|
|
625
|
+
const err = createRPCError(errorCode, errorMessage, errorDetails);
|
|
626
|
+
pending.reject(err);
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
// ─── Internal: Bad message handling ──────────────────────────
|
|
630
|
+
|
|
631
|
+
private _onBadMessage(rawMessage: string, error: Error): void {
|
|
632
|
+
this._badMessageCount++;
|
|
633
|
+
this.emit("badMessage", { message: rawMessage, error });
|
|
634
|
+
|
|
635
|
+
if (this._badMessageCount >= this._options.maxBadMessages) {
|
|
636
|
+
this.close({ code: 1002, reason: "Too many bad messages" }).catch(
|
|
637
|
+
() => {},
|
|
638
|
+
);
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
// ─── Internal: Close handling ────────────────────────────────
|
|
643
|
+
|
|
644
|
+
private _onClose(code: number, reason: Buffer): void {
|
|
645
|
+
this._stopPing();
|
|
646
|
+
const reasonStr = reason.toString();
|
|
647
|
+
|
|
648
|
+
// Reject all pending calls
|
|
649
|
+
for (const [, pending] of this._pendingCalls) {
|
|
650
|
+
clearTimeout(pending.timeoutHandle);
|
|
651
|
+
pending.reject(new Error(`Connection closed (${code}: ${reasonStr})`));
|
|
652
|
+
}
|
|
653
|
+
this._pendingCalls.clear();
|
|
654
|
+
this._pendingResponses.clear();
|
|
655
|
+
|
|
656
|
+
if (this._state !== CLOSING) {
|
|
657
|
+
// Unexpected close — attempt reconnect
|
|
658
|
+
this._state = CLOSED;
|
|
659
|
+
this.emit("close", { code, reason: reasonStr });
|
|
660
|
+
|
|
661
|
+
if (
|
|
662
|
+
this._options.reconnect &&
|
|
663
|
+
this._reconnectAttempt < this._options.maxReconnects
|
|
664
|
+
) {
|
|
665
|
+
this._scheduleReconnect();
|
|
666
|
+
}
|
|
667
|
+
} else {
|
|
668
|
+
this._state = CLOSED;
|
|
669
|
+
// close() handles the emit
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// ─── Internal: Reconnection ──────────────────────────────────
|
|
674
|
+
|
|
675
|
+
private _scheduleReconnect(): void {
|
|
676
|
+
this._reconnectAttempt++;
|
|
677
|
+
|
|
678
|
+
// Exponential backoff with jitter
|
|
679
|
+
const base = this._options.backoffMin;
|
|
680
|
+
const max = this._options.backoffMax;
|
|
681
|
+
const delayMs = Math.min(
|
|
682
|
+
max,
|
|
683
|
+
base *
|
|
684
|
+
Math.pow(2, this._reconnectAttempt - 1) *
|
|
685
|
+
(0.5 + Math.random() * 0.5),
|
|
686
|
+
);
|
|
687
|
+
|
|
688
|
+
this.emit("reconnect", { attempt: this._reconnectAttempt, delay: delayMs });
|
|
689
|
+
|
|
690
|
+
this._reconnectTimer = setTimeoutCb(async () => {
|
|
691
|
+
this._reconnectTimer = null;
|
|
692
|
+
try {
|
|
693
|
+
this._state = CLOSED; // Reset for connect
|
|
694
|
+
await this._connectInternal();
|
|
695
|
+
} catch {
|
|
696
|
+
if (
|
|
697
|
+
this._reconnectAttempt < this._options.maxReconnects &&
|
|
698
|
+
this._options.reconnect
|
|
699
|
+
) {
|
|
700
|
+
this._scheduleReconnect();
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
}, delayMs);
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// ─── Internal: Ping/Pong ─────────────────────────────────────
|
|
707
|
+
|
|
708
|
+
private _startPing(): void {
|
|
709
|
+
if (this._options.pingIntervalMs <= 0) return;
|
|
710
|
+
|
|
711
|
+
const doPing = () => {
|
|
712
|
+
if (this._state !== OPEN || !this._ws) return;
|
|
713
|
+
|
|
714
|
+
if (this._options.deferPingsOnActivity) {
|
|
715
|
+
const elapsed = Date.now() - this._lastActivity;
|
|
716
|
+
if (elapsed < this._options.pingIntervalMs) {
|
|
717
|
+
this._pingTimer = setTimeoutCb(
|
|
718
|
+
doPing,
|
|
719
|
+
this._options.pingIntervalMs - elapsed,
|
|
720
|
+
);
|
|
721
|
+
return;
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
this._ws.ping();
|
|
726
|
+
this._pingTimer = setTimeoutCb(doPing, this._options.pingIntervalMs);
|
|
727
|
+
};
|
|
728
|
+
|
|
729
|
+
this._pingTimer = setTimeoutCb(doPing, this._options.pingIntervalMs);
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
private _stopPing(): void {
|
|
733
|
+
if (this._pingTimer) {
|
|
734
|
+
clearTimeout(this._pingTimer);
|
|
735
|
+
this._pingTimer = null;
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
private _recordActivity(): void {
|
|
740
|
+
this._lastActivity = Date.now();
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
// ─── Internal: Validation ────────────────────────────────────
|
|
744
|
+
|
|
745
|
+
private _setupValidators(): void {
|
|
746
|
+
if (this._options.strictModeValidators) {
|
|
747
|
+
this._validators = this._options.strictModeValidators;
|
|
748
|
+
} else {
|
|
749
|
+
this._validators = standardValidators;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
if (Array.isArray(this._options.strictMode)) {
|
|
753
|
+
this._strictProtocols = this._options.strictMode;
|
|
754
|
+
} else {
|
|
755
|
+
this._strictProtocols = null;
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
private _validateOutbound(
|
|
760
|
+
method: string,
|
|
761
|
+
params: unknown,
|
|
762
|
+
suffix: "req" | "conf",
|
|
763
|
+
): void {
|
|
764
|
+
const validator = this._findValidator();
|
|
765
|
+
if (!validator) return;
|
|
766
|
+
|
|
767
|
+
const schemaId = `urn:${method}.${suffix}`;
|
|
768
|
+
try {
|
|
769
|
+
validator.validate(schemaId, params);
|
|
770
|
+
} catch (err) {
|
|
771
|
+
this.emit("strictValidationFailure", {
|
|
772
|
+
message: params,
|
|
773
|
+
error: err as Error,
|
|
774
|
+
});
|
|
775
|
+
throw err;
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
private _validateInbound(
|
|
780
|
+
method: string,
|
|
781
|
+
params: unknown,
|
|
782
|
+
suffix: "req" | "conf",
|
|
783
|
+
): void {
|
|
784
|
+
const validator = this._findValidator();
|
|
785
|
+
if (!validator) return;
|
|
786
|
+
|
|
787
|
+
const schemaId = `urn:${method}.${suffix}`;
|
|
788
|
+
try {
|
|
789
|
+
validator.validate(schemaId, params);
|
|
790
|
+
} catch (err) {
|
|
791
|
+
this.emit("strictValidationFailure", {
|
|
792
|
+
message: params,
|
|
793
|
+
error: err as Error,
|
|
794
|
+
});
|
|
795
|
+
throw err;
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
private _findValidator(): Validator | null {
|
|
800
|
+
if (!this._protocol) return null;
|
|
801
|
+
|
|
802
|
+
if (
|
|
803
|
+
this._strictProtocols &&
|
|
804
|
+
!this._strictProtocols.includes(this._protocol)
|
|
805
|
+
) {
|
|
806
|
+
return null;
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
return (
|
|
810
|
+
this._validators.find((v) => v.subprotocol === this._protocol) ?? null
|
|
811
|
+
);
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
// ─── Internal: Endpoint building ─────────────────────────────
|
|
815
|
+
|
|
816
|
+
private _buildEndpoint(): string {
|
|
817
|
+
let url = this._options.endpoint;
|
|
818
|
+
|
|
819
|
+
// Append identity to URL path
|
|
820
|
+
if (!url.endsWith("/")) url += "/";
|
|
821
|
+
url += encodeURIComponent(this._identity);
|
|
822
|
+
|
|
823
|
+
// Append query parameters
|
|
824
|
+
if (this._options.query) {
|
|
825
|
+
const params = new URLSearchParams(this._options.query);
|
|
826
|
+
url += (url.includes("?") ? "&" : "?") + params.toString();
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
return url;
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
private _buildWsOptions(): WebSocket.ClientOptions {
|
|
833
|
+
const opts: WebSocket.ClientOptions = {
|
|
834
|
+
headers: {
|
|
835
|
+
...this._options.headers,
|
|
836
|
+
"User-Agent": getPackageIdent(),
|
|
837
|
+
},
|
|
838
|
+
};
|
|
839
|
+
|
|
840
|
+
const profile = this._options.securityProfile ?? SecurityProfile.NONE;
|
|
841
|
+
|
|
842
|
+
// Profile 1 & 2: Basic Auth header
|
|
843
|
+
if (
|
|
844
|
+
(profile === SecurityProfile.BASIC_AUTH ||
|
|
845
|
+
profile === SecurityProfile.TLS_BASIC_AUTH) &&
|
|
846
|
+
this._options.password
|
|
847
|
+
) {
|
|
848
|
+
const credentials = Buffer.from(
|
|
849
|
+
`${this._identity}:${this._options.password.toString()}`,
|
|
850
|
+
).toString("base64");
|
|
851
|
+
opts.headers!["Authorization"] = `Basic ${credentials}`;
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
// Profile 2 & 3: TLS options
|
|
855
|
+
if (
|
|
856
|
+
profile === SecurityProfile.TLS_BASIC_AUTH ||
|
|
857
|
+
profile === SecurityProfile.TLS_CLIENT_CERT
|
|
858
|
+
) {
|
|
859
|
+
const tls = this._options.tls ?? {};
|
|
860
|
+
if (tls.ca) opts.ca = tls.ca;
|
|
861
|
+
if (tls.rejectUnauthorized !== undefined)
|
|
862
|
+
opts.rejectUnauthorized = tls.rejectUnauthorized;
|
|
863
|
+
|
|
864
|
+
// Profile 3: Client certificates for mTLS
|
|
865
|
+
if (profile === SecurityProfile.TLS_CLIENT_CERT) {
|
|
866
|
+
if (tls.cert) opts.cert = tls.cert;
|
|
867
|
+
if (tls.key) opts.key = tls.key;
|
|
868
|
+
if (tls.passphrase) opts.passphrase = tls.passphrase;
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
return opts;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
// ─── Internal: Cleanup ───────────────────────────────────────
|
|
876
|
+
|
|
877
|
+
private _cleanup(): void {
|
|
878
|
+
this._stopPing();
|
|
879
|
+
this._closePromise = null;
|
|
880
|
+
this._ws = null;
|
|
881
|
+
}
|
|
882
|
+
}
|