fix: ensure OCPP request timeouting cancel it
[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 );
387 let sendError = false;
388 // Check if wsConnection opened
389 const wsOpened = chargingStation.isWebSocketConnectionOpened() === true;
390 if (wsOpened) {
391 const beginId = PerformanceStatistics.beginMeasure(commandName);
392 try {
393 setTimeout(() => {
394 return errorCallback(
395 new OCPPError(
396 ErrorType.GENERIC_ERROR,
397 `Timeout for message id '${messageId}'`,
398 commandName,
399 (messagePayload as JsonObject)?.details ?? Constants.EMPTY_FROZEN_OBJECT,
400 ),
401 false,
18bf8274 402 );
1b2acf4e
JB
403 }, OCPPConstants.OCPP_WEBSOCKET_TIMEOUT);
404 chargingStation.wsConnection?.send(messageToSend);
405 logger.debug(
406 `${chargingStation.logPrefix()} >> Command '${commandName}' sent ${OCPPServiceUtils.getMessageTypeString(
407 messageType,
408 )} payload: ${messageToSend}`,
409 );
410 } catch (error) {
411 logger.error(
412 `${chargingStation.logPrefix()} >> Command '${commandName}' failed to send ${OCPPServiceUtils.getMessageTypeString(
413 messageType,
414 )} payload: ${messageToSend}:`,
415 error,
1b821a64 416 );
1b2acf4e
JB
417 sendError = true;
418 }
419 PerformanceStatistics.endMeasure(commandName, beginId);
420 }
421 const wsClosedOrErrored = !wsOpened || sendError === true;
422 if (wsClosedOrErrored && params?.skipBufferingOnError === false) {
423 // Buffer
424 chargingStation.bufferMessage(messageToSend);
425 // Reject and keep request in the cache
426 return reject(
427 new OCPPError(
e7aeea18 428 ErrorType.GENERIC_ERROR,
1b2acf4e 429 `WebSocket closed or errored for buffered message id '${messageId}' with content '${messageToSend}'`,
e7aeea18 430 commandName,
2035255d 431 (messagePayload as JsonObject)?.details ?? Constants.EMPTY_FROZEN_OBJECT,
1b2acf4e
JB
432 ),
433 );
434 } else if (wsClosedOrErrored) {
435 const ocppError = new OCPPError(
436 ErrorType.GENERIC_ERROR,
437 `WebSocket closed or errored for non buffered message id '${messageId}' with content '${messageToSend}'`,
438 commandName,
439 (messagePayload as JsonObject)?.details ?? Constants.EMPTY_FROZEN_OBJECT,
440 );
441 // Reject response
e7aeea18 442 if (messageType !== MessageType.CALL_MESSAGE) {
1b2acf4e 443 return reject(ocppError);
e7aeea18 444 }
1b2acf4e
JB
445 // Reject and remove request from the cache
446 return errorCallback(ocppError, false);
447 }
448 // Resolve response
449 if (messageType !== MessageType.CALL_MESSAGE) {
450 return resolve(messagePayload);
451 }
452 });
caad9d6b 453 }
e7aeea18
JB
454 throw new OCPPError(
455 ErrorType.SECURITY_ERROR,
e3018bc4 456 `Cannot send command ${commandName} PDU when the charging station is in ${chargingStation.getRegistrationStatus()} state on the central server`,
5edd8ba0 457 commandName,
e7aeea18 458 );
c0560973
JB
459 }
460
e7aeea18 461 private buildMessageToSend(
08f130a0 462 chargingStation: ChargingStation,
e7aeea18 463 messageId: string,
5cc4b63b 464 messagePayload: JsonType | OCPPError,
e7aeea18 465 messageType: MessageType,
72092cfc
JB
466 commandName: RequestCommand | IncomingRequestCommand,
467 responseCallback: ResponseCallback,
5edd8ba0 468 errorCallback: ErrorCallback,
e7aeea18 469 ): string {
e7accadb
JB
470 let messageToSend: string;
471 // Type of message
472 switch (messageType) {
473 // Request
474 case MessageType.CALL_MESSAGE:
475 // Build request
291b5ec8 476 this.validateRequestPayload(chargingStation, commandName, messagePayload as JsonType);
08f130a0 477 chargingStation.requests.set(messageId, [
e7aeea18 478 responseCallback,
a2d1c0f1 479 errorCallback,
e7aeea18 480 commandName,
5cc4b63b 481 messagePayload as JsonType,
e7aeea18 482 ]);
b3ec7bc1
JB
483 messageToSend = JSON.stringify([
484 messageType,
485 messageId,
486 commandName,
487 messagePayload,
488 ] as OutgoingRequest);
e7accadb
JB
489 break;
490 // Response
491 case MessageType.CALL_RESULT_MESSAGE:
492 // Build response
02887891
JB
493 this.validateIncomingRequestResponsePayload(
494 chargingStation,
495 commandName,
291b5ec8 496 messagePayload as JsonType,
02887891 497 );
b3ec7bc1 498 messageToSend = JSON.stringify([messageType, messageId, messagePayload] as Response);
e7accadb
JB
499 break;
500 // Error Message
501 case MessageType.CALL_ERROR_MESSAGE:
502 // Build Error Message
e7aeea18
JB
503 messageToSend = JSON.stringify([
504 messageType,
505 messageId,
b3ec7bc1
JB
506 (messagePayload as OCPPError)?.code ?? ErrorType.GENERIC_ERROR,
507 (messagePayload as OCPPError)?.message ?? '',
508 (messagePayload as OCPPError)?.details ?? { commandName },
509 ] as ErrorResponse);
e7accadb
JB
510 break;
511 }
512 return messageToSend;
513 }
514
ef6fa3fb 515 // eslint-disable-next-line @typescript-eslint/no-unused-vars
e0b0ee21 516 public abstract requestHandler<ReqType extends JsonType, ResType extends JsonType>(
08f130a0 517 chargingStation: ChargingStation,
94a464f9 518 commandName: RequestCommand,
e1d9a0f4 519 // FIXME: should be ReqType
5cc4b63b 520 commandParams?: JsonType,
5edd8ba0 521 params?: RequestParams,
e0b0ee21 522 ): Promise<ResType>;
c0560973 523}