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