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