build: properly workaround Ajv TS type definitions bug
[e-mobility-charging-stations-simulator.git] / src / charging-station / ocpp / OCPPRequestService.ts
1 import _Ajv, { type JSONSchemaType, type ValidateFunction } from 'ajv';
2 import _ajvFormats from 'ajv-formats';
3
4 import { OCPPConstants } from './OCPPConstants.js';
5 import type { OCPPResponseService } from './OCPPResponseService.js';
6 import { OCPPServiceUtils } from './OCPPServiceUtils.js';
7 import type { ChargingStation } from '../../charging-station/index.js';
8 import { OCPPError } from '../../exception/index.js';
9 import { PerformanceStatistics } from '../../performance/index.js';
10 import {
11 ChargingStationEvents,
12 type ErrorCallback,
13 type ErrorResponse,
14 ErrorType,
15 type IncomingRequestCommand,
16 type JsonType,
17 MessageType,
18 type OCPPVersion,
19 type OutgoingRequest,
20 RequestCommand,
21 type RequestParams,
22 type Response,
23 type ResponseCallback,
24 type ResponseType,
25 } from '../../types/index.js';
26 import {
27 cloneObject,
28 formatDurationMilliSeconds,
29 handleSendMessageError,
30 isNullOrUndefined,
31 logger,
32 } from '../../utils/index.js';
33 type Ajv = _Ajv.default;
34 const Ajv = _Ajv.default;
35 const ajvFormats = _ajvFormats.default;
36
37 const moduleName = 'OCPPRequestService';
38
39 const defaultRequestParams: RequestParams = {
40 skipBufferingOnError: false,
41 triggerMessage: false,
42 throwError: false,
43 };
44
45 export abstract class OCPPRequestService {
46 private static instance: OCPPRequestService | null = null;
47 private readonly version: OCPPVersion;
48 private readonly ajv: Ajv;
49 private readonly ocppResponseService: OCPPResponseService;
50 private readonly jsonValidateFunctions: Map<RequestCommand, ValidateFunction<JsonType>>;
51 protected abstract jsonSchemas: Map<RequestCommand, JSONSchemaType<JsonType>>;
52
53 protected constructor(version: OCPPVersion, ocppResponseService: OCPPResponseService) {
54 this.version = version;
55 this.ajv = new Ajv({
56 keywords: ['javaType'],
57 multipleOfPrecision: 2,
58 });
59 ajvFormats(this.ajv);
60 this.jsonValidateFunctions = new Map<RequestCommand, ValidateFunction<JsonType>>();
61 this.ocppResponseService = ocppResponseService;
62 this.requestHandler = this.requestHandler.bind(this) as <
63 // eslint-disable-next-line @typescript-eslint/no-unused-vars
64 ReqType extends JsonType,
65 ResType extends JsonType,
66 >(
67 chargingStation: ChargingStation,
68 commandName: RequestCommand,
69 commandParams?: JsonType,
70 params?: RequestParams,
71 ) => Promise<ResType>;
72 this.sendMessage = this.sendMessage.bind(this) as (
73 chargingStation: ChargingStation,
74 messageId: string,
75 messagePayload: JsonType,
76 commandName: RequestCommand,
77 params?: RequestParams,
78 ) => Promise<ResponseType>;
79 this.sendResponse = this.sendResponse.bind(this) as (
80 chargingStation: ChargingStation,
81 messageId: string,
82 messagePayload: JsonType,
83 commandName: IncomingRequestCommand,
84 ) => Promise<ResponseType>;
85 this.sendError = this.sendError.bind(this) as (
86 chargingStation: ChargingStation,
87 messageId: string,
88 ocppError: OCPPError,
89 commandName: RequestCommand | IncomingRequestCommand,
90 ) => Promise<ResponseType>;
91 this.internalSendMessage = this.internalSendMessage.bind(this) as (
92 chargingStation: ChargingStation,
93 messageId: string,
94 messagePayload: JsonType | OCPPError,
95 messageType: MessageType,
96 commandName: RequestCommand | IncomingRequestCommand,
97 params?: RequestParams,
98 ) => Promise<ResponseType>;
99 this.buildMessageToSend = this.buildMessageToSend.bind(this) as (
100 chargingStation: ChargingStation,
101 messageId: string,
102 messagePayload: JsonType | OCPPError,
103 messageType: MessageType,
104 commandName: RequestCommand | IncomingRequestCommand,
105 ) => string;
106 this.validateRequestPayload = this.validateRequestPayload.bind(this) as <T extends JsonType>(
107 chargingStation: ChargingStation,
108 commandName: RequestCommand | IncomingRequestCommand,
109 payload: T,
110 ) => boolean;
111 this.validateIncomingRequestResponsePayload = this.validateIncomingRequestResponsePayload.bind(
112 this,
113 ) as <T extends JsonType>(
114 chargingStation: ChargingStation,
115 commandName: RequestCommand | IncomingRequestCommand,
116 payload: T,
117 ) => boolean;
118 }
119
120 public static getInstance<T extends OCPPRequestService>(
121 this: new (ocppResponseService: OCPPResponseService) => T,
122 ocppResponseService: OCPPResponseService,
123 ): T {
124 if (OCPPRequestService.instance === null) {
125 OCPPRequestService.instance = new this(ocppResponseService);
126 }
127 return OCPPRequestService.instance as T;
128 }
129
130 public async sendResponse(
131 chargingStation: ChargingStation,
132 messageId: string,
133 messagePayload: JsonType,
134 commandName: IncomingRequestCommand,
135 ): Promise<ResponseType> {
136 try {
137 // Send response message
138 return await this.internalSendMessage(
139 chargingStation,
140 messageId,
141 messagePayload,
142 MessageType.CALL_RESULT_MESSAGE,
143 commandName,
144 );
145 } catch (error) {
146 handleSendMessageError(chargingStation, commandName, error as Error, {
147 throwError: true,
148 });
149 return null;
150 }
151 }
152
153 public async sendError(
154 chargingStation: ChargingStation,
155 messageId: string,
156 ocppError: OCPPError,
157 commandName: RequestCommand | IncomingRequestCommand,
158 ): Promise<ResponseType> {
159 try {
160 // Send error message
161 return await this.internalSendMessage(
162 chargingStation,
163 messageId,
164 ocppError,
165 MessageType.CALL_ERROR_MESSAGE,
166 commandName,
167 );
168 } catch (error) {
169 handleSendMessageError(chargingStation, commandName, error as Error);
170 return null;
171 }
172 }
173
174 protected async sendMessage(
175 chargingStation: ChargingStation,
176 messageId: string,
177 messagePayload: JsonType,
178 commandName: RequestCommand,
179 params?: RequestParams,
180 ): Promise<ResponseType> {
181 params = {
182 ...defaultRequestParams,
183 ...params,
184 };
185 try {
186 return await this.internalSendMessage(
187 chargingStation,
188 messageId,
189 messagePayload,
190 MessageType.CALL_MESSAGE,
191 commandName,
192 params,
193 );
194 } catch (error) {
195 handleSendMessageError(chargingStation, commandName, error as Error, {
196 throwError: params.throwError,
197 });
198 return null;
199 }
200 }
201
202 private validateRequestPayload<T extends JsonType>(
203 chargingStation: ChargingStation,
204 commandName: RequestCommand | IncomingRequestCommand,
205 payload: T,
206 ): boolean {
207 if (chargingStation.stationInfo?.ocppStrictCompliance === false) {
208 return true;
209 }
210 if (this.jsonSchemas.has(commandName as RequestCommand) === false) {
211 logger.warn(
212 `${chargingStation.logPrefix()} ${moduleName}.validateRequestPayload: No JSON schema found for command '${commandName}' PDU validation`,
213 );
214 return true;
215 }
216 const validate = this.getJsonRequestValidateFunction<T>(commandName as RequestCommand);
217 payload = cloneObject<T>(payload);
218 OCPPServiceUtils.convertDateToISOString<T>(payload);
219 if (validate(payload)) {
220 return true;
221 }
222 logger.error(
223 `${chargingStation.logPrefix()} ${moduleName}.validateRequestPayload: Command '${commandName}' request PDU is invalid: %j`,
224 validate.errors,
225 );
226 // OCPPError usage here is debatable: it's an error in the OCPP stack but not targeted to sendError().
227 throw new OCPPError(
228 OCPPServiceUtils.ajvErrorsToErrorType(validate.errors),
229 'Request PDU is invalid',
230 commandName,
231 JSON.stringify(validate.errors, undefined, 2),
232 );
233 }
234
235 private getJsonRequestValidateFunction<T extends JsonType>(commandName: RequestCommand) {
236 if (this.jsonValidateFunctions.has(commandName) === false) {
237 this.jsonValidateFunctions.set(
238 commandName,
239 this.ajv.compile<T>(this.jsonSchemas.get(commandName)!).bind(this),
240 );
241 }
242 return this.jsonValidateFunctions.get(commandName)!;
243 }
244
245 private validateIncomingRequestResponsePayload<T extends JsonType>(
246 chargingStation: ChargingStation,
247 commandName: RequestCommand | IncomingRequestCommand,
248 payload: T,
249 ): boolean {
250 if (chargingStation.stationInfo?.ocppStrictCompliance === false) {
251 return true;
252 }
253 if (
254 this.ocppResponseService.jsonIncomingRequestResponseSchemas.has(
255 commandName as IncomingRequestCommand,
256 ) === false
257 ) {
258 logger.warn(
259 `${chargingStation.logPrefix()} ${moduleName}.validateIncomingRequestResponsePayload: No JSON schema found for command '${commandName}' PDU validation`,
260 );
261 return true;
262 }
263 const validate = this.getJsonRequestResponseValidateFunction<T>(
264 commandName as IncomingRequestCommand,
265 );
266 payload = cloneObject<T>(payload);
267 OCPPServiceUtils.convertDateToISOString<T>(payload);
268 if (validate(payload)) {
269 return true;
270 }
271 logger.error(
272 `${chargingStation.logPrefix()} ${moduleName}.validateIncomingRequestResponsePayload: Command '${commandName}' reponse PDU is invalid: %j`,
273 validate.errors,
274 );
275 // OCPPError usage here is debatable: it's an error in the OCPP stack but not targeted to sendError().
276 throw new OCPPError(
277 OCPPServiceUtils.ajvErrorsToErrorType(validate.errors),
278 'Response PDU is invalid',
279 commandName,
280 JSON.stringify(validate.errors, undefined, 2),
281 );
282 }
283
284 private getJsonRequestResponseValidateFunction<T extends JsonType>(
285 commandName: IncomingRequestCommand,
286 ) {
287 if (
288 this.ocppResponseService.jsonIncomingRequestResponseValidateFunctions.has(commandName) ===
289 false
290 ) {
291 this.ocppResponseService.jsonIncomingRequestResponseValidateFunctions.set(
292 commandName,
293 this.ajv
294 .compile<T>(this.ocppResponseService.jsonIncomingRequestResponseSchemas.get(commandName)!)
295 .bind(this),
296 );
297 }
298 return this.ocppResponseService.jsonIncomingRequestResponseValidateFunctions.get(commandName)!;
299 }
300
301 private async internalSendMessage(
302 chargingStation: ChargingStation,
303 messageId: string,
304 messagePayload: JsonType | OCPPError,
305 messageType: MessageType,
306 commandName: RequestCommand | IncomingRequestCommand,
307 params?: RequestParams,
308 ): Promise<ResponseType> {
309 params = {
310 ...defaultRequestParams,
311 ...params,
312 };
313 if (
314 (chargingStation.inUnknownState() === true &&
315 commandName === RequestCommand.BOOT_NOTIFICATION) ||
316 (chargingStation.stationInfo?.ocppStrictCompliance === false &&
317 chargingStation.inUnknownState() === true) ||
318 chargingStation.inAcceptedState() === true ||
319 (chargingStation.inPendingState() === true &&
320 (params.triggerMessage === true || messageType === MessageType.CALL_RESULT_MESSAGE))
321 ) {
322 // eslint-disable-next-line @typescript-eslint/no-this-alias
323 const self = this;
324 // Send a message through wsConnection
325 return new Promise<ResponseType>((resolve, reject) => {
326 /**
327 * Function that will receive the request's response
328 *
329 * @param payload -
330 * @param requestPayload -
331 */
332 const responseCallback = (payload: JsonType, requestPayload: JsonType): void => {
333 if (chargingStation.stationInfo?.enableStatistics === true) {
334 chargingStation.performanceStatistics?.addRequestStatistic(
335 commandName,
336 MessageType.CALL_RESULT_MESSAGE,
337 );
338 }
339 // Handle the request's response
340 self.ocppResponseService
341 .responseHandler(
342 chargingStation,
343 commandName as RequestCommand,
344 payload,
345 requestPayload,
346 )
347 .then(() => {
348 resolve(payload);
349 })
350 .catch(reject)
351 .finally(() => {
352 chargingStation.requests.delete(messageId);
353 chargingStation.emit(ChargingStationEvents.updated);
354 });
355 };
356
357 /**
358 * Function that will receive the request's error response
359 *
360 * @param ocppError -
361 * @param requestStatistic -
362 */
363 const errorCallback = (ocppError: OCPPError, requestStatistic = true): void => {
364 if (requestStatistic === true && chargingStation.stationInfo?.enableStatistics === true) {
365 chargingStation.performanceStatistics?.addRequestStatistic(
366 commandName,
367 MessageType.CALL_ERROR_MESSAGE,
368 );
369 }
370 logger.error(
371 `${chargingStation.logPrefix()} Error occurred at ${OCPPServiceUtils.getMessageTypeString(
372 messageType,
373 )} command ${commandName} with PDU %j:`,
374 messagePayload,
375 ocppError,
376 );
377 chargingStation.requests.delete(messageId);
378 chargingStation.emit(ChargingStationEvents.updated);
379 reject(ocppError);
380 };
381
382 const handleSendError = (ocppError: OCPPError): void => {
383 if (params?.skipBufferingOnError === false) {
384 // Buffer
385 chargingStation.bufferMessage(messageToSend);
386 if (messageType === MessageType.CALL_MESSAGE) {
387 this.cacheRequestPromise(
388 chargingStation,
389 messageId,
390 messagePayload as JsonType,
391 commandName,
392 responseCallback,
393 errorCallback,
394 );
395 }
396 } else if (
397 params?.skipBufferingOnError === true &&
398 messageType === MessageType.CALL_MESSAGE
399 ) {
400 // Remove request from the cache
401 chargingStation.requests.delete(messageId);
402 }
403 return reject(ocppError);
404 };
405
406 if (chargingStation.stationInfo?.enableStatistics === true) {
407 chargingStation.performanceStatistics?.addRequestStatistic(commandName, messageType);
408 }
409 const messageToSend = this.buildMessageToSend(
410 chargingStation,
411 messageId,
412 messagePayload,
413 messageType,
414 commandName,
415 );
416 // Check if wsConnection opened
417 if (chargingStation.isWebSocketConnectionOpened() === true) {
418 const beginId = PerformanceStatistics.beginMeasure(commandName);
419 const sendTimeout = setTimeout(() => {
420 return handleSendError(
421 new OCPPError(
422 ErrorType.GENERIC_ERROR,
423 `Timeout ${formatDurationMilliSeconds(
424 OCPPConstants.OCPP_WEBSOCKET_TIMEOUT,
425 )} reached for ${
426 params?.skipBufferingOnError === false ? '' : 'non '
427 }buffered message id '${messageId}' with content '${messageToSend}'`,
428 commandName,
429 (messagePayload as OCPPError).details,
430 ),
431 );
432 }, OCPPConstants.OCPP_WEBSOCKET_TIMEOUT);
433 chargingStation.wsConnection?.send(messageToSend, (error?: Error) => {
434 PerformanceStatistics.endMeasure(commandName, beginId);
435 clearTimeout(sendTimeout);
436 if (isNullOrUndefined(error)) {
437 logger.debug(
438 `${chargingStation.logPrefix()} >> Command '${commandName}' sent ${OCPPServiceUtils.getMessageTypeString(
439 messageType,
440 )} payload: ${messageToSend}`,
441 );
442 if (messageType === MessageType.CALL_MESSAGE) {
443 this.cacheRequestPromise(
444 chargingStation,
445 messageId,
446 messagePayload as JsonType,
447 commandName,
448 responseCallback,
449 errorCallback,
450 );
451 } else {
452 // Resolve response
453 return resolve(messagePayload);
454 }
455 } else if (error) {
456 return handleSendError(
457 new OCPPError(
458 ErrorType.GENERIC_ERROR,
459 `WebSocket errored for ${
460 params?.skipBufferingOnError === false ? '' : 'non '
461 }buffered message id '${messageId}' with content '${messageToSend}'`,
462 commandName,
463 { name: error.name, message: error.message, stack: error.stack },
464 ),
465 );
466 }
467 });
468 } else {
469 return handleSendError(
470 new OCPPError(
471 ErrorType.GENERIC_ERROR,
472 `WebSocket closed for ${
473 params?.skipBufferingOnError === false ? '' : 'non '
474 }buffered message id '${messageId}' with content '${messageToSend}'`,
475 commandName,
476 (messagePayload as OCPPError).details,
477 ),
478 );
479 }
480 });
481 }
482 throw new OCPPError(
483 ErrorType.SECURITY_ERROR,
484 `Cannot send command ${commandName} PDU when the charging station is in ${chargingStation?.bootNotificationResponse?.status} state on the central server`,
485 commandName,
486 );
487 }
488
489 private buildMessageToSend(
490 chargingStation: ChargingStation,
491 messageId: string,
492 messagePayload: JsonType | OCPPError,
493 messageType: MessageType,
494 commandName: RequestCommand | IncomingRequestCommand,
495 ): string {
496 let messageToSend: string;
497 // Type of message
498 switch (messageType) {
499 // Request
500 case MessageType.CALL_MESSAGE:
501 // Build request
502 this.validateRequestPayload(chargingStation, commandName, messagePayload as JsonType);
503 messageToSend = JSON.stringify([
504 messageType,
505 messageId,
506 commandName,
507 messagePayload,
508 ] as OutgoingRequest);
509 break;
510 // Response
511 case MessageType.CALL_RESULT_MESSAGE:
512 // Build response
513 this.validateIncomingRequestResponsePayload(
514 chargingStation,
515 commandName,
516 messagePayload as JsonType,
517 );
518 messageToSend = JSON.stringify([messageType, messageId, messagePayload] as Response);
519 break;
520 // Error Message
521 case MessageType.CALL_ERROR_MESSAGE:
522 // Build Error Message
523 messageToSend = JSON.stringify([
524 messageType,
525 messageId,
526 (messagePayload as OCPPError).code,
527 (messagePayload as OCPPError).message,
528 (messagePayload as OCPPError).details ?? {
529 command: (messagePayload as OCPPError).command ?? commandName,
530 },
531 ] as ErrorResponse);
532 break;
533 }
534 return messageToSend;
535 }
536
537 private cacheRequestPromise(
538 chargingStation: ChargingStation,
539 messageId: string,
540 messagePayload: JsonType,
541 commandName: RequestCommand | IncomingRequestCommand,
542 responseCallback: ResponseCallback,
543 errorCallback: ErrorCallback,
544 ): void {
545 chargingStation.requests.set(messageId, [
546 responseCallback,
547 errorCallback,
548 commandName,
549 messagePayload,
550 ]);
551 }
552
553 // eslint-disable-next-line @typescript-eslint/no-unused-vars
554 public abstract requestHandler<ReqType extends JsonType, ResType extends JsonType>(
555 chargingStation: ChargingStation,
556 commandName: RequestCommand,
557 // FIXME: should be ReqType
558 commandParams?: JsonType,
559 params?: RequestParams,
560 ): Promise<ResType>;
561 }