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