fix: ensure OCPP request timeouting cancel it
[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';
5 import type { OCPPResponseService } from './OCPPResponseService';
6 import { OCPPServiceUtils } from './OCPPServiceUtils';
7 import type { ChargingStation } from '../../charging-station';
8 import { OCPPError } from '../../exception';
9 import { PerformanceStatistics } from '../../performance';
10 import {
11 type ErrorCallback,
12 type ErrorResponse,
13 ErrorType,
14 type IncomingRequestCommand,
15 type JsonObject,
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';
26 import { Constants, cloneObject, handleSendMessageError, logger } from '../../utils';
27
28 const moduleName = 'OCPPRequestService';
29
30 const defaultRequestParams: RequestParams = {
31 skipBufferingOnError: false,
32 triggerMessage: false,
33 throwError: false,
34 };
35
36 export abstract class OCPPRequestService {
37 private static instance: OCPPRequestService | null = null;
38 private readonly version: OCPPVersion;
39 private readonly ajv: Ajv;
40 private readonly ocppResponseService: OCPPResponseService;
41 private readonly jsonValidateFunctions: Map<RequestCommand, ValidateFunction<JsonType>>;
42 protected abstract jsonSchemas: Map<RequestCommand, JSONSchemaType<JsonType>>;
43
44 protected constructor(version: OCPPVersion, ocppResponseService: OCPPResponseService) {
45 this.version = version;
46 this.ajv = new Ajv({
47 keywords: ['javaType'],
48 multipleOfPrecision: 2,
49 });
50 ajvFormats(this.ajv);
51 this.jsonValidateFunctions = new Map<RequestCommand, ValidateFunction<JsonType>>();
52 this.ocppResponseService = ocppResponseService;
53 this.requestHandler = this.requestHandler.bind(this) as <
54 // eslint-disable-next-line @typescript-eslint/no-unused-vars
55 ReqType extends JsonType,
56 ResType extends JsonType,
57 >(
58 chargingStation: ChargingStation,
59 commandName: RequestCommand,
60 commandParams?: JsonType,
61 params?: RequestParams,
62 ) => Promise<ResType>;
63 this.sendMessage = this.sendMessage.bind(this) as (
64 chargingStation: ChargingStation,
65 messageId: string,
66 messagePayload: JsonType,
67 commandName: RequestCommand,
68 params?: RequestParams,
69 ) => Promise<ResponseType>;
70 this.sendResponse = this.sendResponse.bind(this) as (
71 chargingStation: ChargingStation,
72 messageId: string,
73 messagePayload: JsonType,
74 commandName: IncomingRequestCommand,
75 ) => Promise<ResponseType>;
76 this.sendError = this.sendError.bind(this) as (
77 chargingStation: ChargingStation,
78 messageId: string,
79 ocppError: OCPPError,
80 commandName: RequestCommand | IncomingRequestCommand,
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,
88 params?: RequestParams,
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,
97 errorCallback: ErrorCallback,
98 ) => string;
99 this.validateRequestPayload = this.validateRequestPayload.bind(this) as <T extends JsonType>(
100 chargingStation: ChargingStation,
101 commandName: RequestCommand | IncomingRequestCommand,
102 payload: T,
103 ) => boolean;
104 this.validateIncomingRequestResponsePayload = this.validateIncomingRequestResponsePayload.bind(
105 this,
106 ) as <T extends JsonType>(
107 chargingStation: ChargingStation,
108 commandName: RequestCommand | IncomingRequestCommand,
109 payload: T,
110 ) => boolean;
111 }
112
113 public static getInstance<T extends OCPPRequestService>(
114 this: new (ocppResponseService: OCPPResponseService) => T,
115 ocppResponseService: OCPPResponseService,
116 ): T {
117 if (OCPPRequestService.instance === null) {
118 OCPPRequestService.instance = new this(ocppResponseService);
119 }
120 return OCPPRequestService.instance as T;
121 }
122
123 public async sendResponse(
124 chargingStation: ChargingStation,
125 messageId: string,
126 messagePayload: JsonType,
127 commandName: IncomingRequestCommand,
128 ): Promise<ResponseType> {
129 try {
130 // Send response message
131 return await this.internalSendMessage(
132 chargingStation,
133 messageId,
134 messagePayload,
135 MessageType.CALL_RESULT_MESSAGE,
136 commandName,
137 );
138 } catch (error) {
139 handleSendMessageError(chargingStation, commandName, error as Error, {
140 throwError: true,
141 });
142 return null;
143 }
144 }
145
146 public async sendError(
147 chargingStation: ChargingStation,
148 messageId: string,
149 ocppError: OCPPError,
150 commandName: RequestCommand | IncomingRequestCommand,
151 ): Promise<ResponseType> {
152 try {
153 // Send error message
154 return await this.internalSendMessage(
155 chargingStation,
156 messageId,
157 ocppError,
158 MessageType.CALL_ERROR_MESSAGE,
159 commandName,
160 );
161 } catch (error) {
162 handleSendMessageError(chargingStation, commandName, error as Error);
163 return null;
164 }
165 }
166
167 protected async sendMessage(
168 chargingStation: ChargingStation,
169 messageId: string,
170 messagePayload: JsonType,
171 commandName: RequestCommand,
172 params?: RequestParams,
173 ): Promise<ResponseType> {
174 params = {
175 ...defaultRequestParams,
176 ...params,
177 };
178 try {
179 return await this.internalSendMessage(
180 chargingStation,
181 messageId,
182 messagePayload,
183 MessageType.CALL_MESSAGE,
184 commandName,
185 params,
186 );
187 } catch (error) {
188 handleSendMessageError(chargingStation, commandName, error as Error, {
189 throwError: params.throwError,
190 });
191 return null;
192 }
193 }
194
195 private validateRequestPayload<T extends JsonType>(
196 chargingStation: ChargingStation,
197 commandName: RequestCommand | IncomingRequestCommand,
198 payload: T,
199 ): boolean {
200 if (chargingStation.stationInfo?.ocppStrictCompliance === false) {
201 return true;
202 }
203 if (this.jsonSchemas.has(commandName as RequestCommand) === false) {
204 logger.warn(
205 `${chargingStation.logPrefix()} ${moduleName}.validateRequestPayload: No JSON schema found for command '${commandName}' PDU validation`,
206 );
207 return true;
208 }
209 const validate = this.getJsonRequestValidateFunction<T>(commandName as RequestCommand);
210 payload = cloneObject<T>(payload);
211 OCPPServiceUtils.convertDateToISOString<T>(payload);
212 if (validate(payload)) {
213 return true;
214 }
215 logger.error(
216 `${chargingStation.logPrefix()} ${moduleName}.validateRequestPayload: Command '${commandName}' request PDU is invalid: %j`,
217 validate.errors,
218 );
219 // OCPPError usage here is debatable: it's an error in the OCPP stack but not targeted to sendError().
220 throw new OCPPError(
221 OCPPServiceUtils.ajvErrorsToErrorType(validate.errors),
222 'Request PDU is invalid',
223 commandName,
224 JSON.stringify(validate.errors, undefined, 2),
225 );
226 }
227
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
238 private validateIncomingRequestResponsePayload<T extends JsonType>(
239 chargingStation: ChargingStation,
240 commandName: RequestCommand | IncomingRequestCommand,
241 payload: T,
242 ): boolean {
243 if (chargingStation.stationInfo?.ocppStrictCompliance === false) {
244 return true;
245 }
246 if (
247 this.ocppResponseService.jsonIncomingRequestResponseSchemas.has(
248 commandName as IncomingRequestCommand,
249 ) === false
250 ) {
251 logger.warn(
252 `${chargingStation.logPrefix()} ${moduleName}.validateIncomingRequestResponsePayload: No JSON schema found for command '${commandName}' PDU validation`,
253 );
254 return true;
255 }
256 const validate = this.getJsonRequestResponseValidateFunction<T>(
257 commandName as IncomingRequestCommand,
258 );
259 payload = cloneObject<T>(payload);
260 OCPPServiceUtils.convertDateToISOString<T>(payload);
261 if (validate(payload)) {
262 return true;
263 }
264 logger.error(
265 `${chargingStation.logPrefix()} ${moduleName}.validateIncomingRequestResponsePayload: Command '${commandName}' reponse PDU is invalid: %j`,
266 validate.errors,
267 );
268 // OCPPError usage here is debatable: it's an error in the OCPP stack but not targeted to sendError().
269 throw new OCPPError(
270 OCPPServiceUtils.ajvErrorsToErrorType(validate.errors),
271 'Response PDU is invalid',
272 commandName,
273 JSON.stringify(validate.errors, undefined, 2),
274 );
275 }
276
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
294 private async internalSendMessage(
295 chargingStation: ChargingStation,
296 messageId: string,
297 messagePayload: JsonType | OCPPError,
298 messageType: MessageType,
299 commandName: RequestCommand | IncomingRequestCommand,
300 params?: RequestParams,
301 ): Promise<ResponseType> {
302 params = {
303 ...defaultRequestParams,
304 ...params,
305 };
306 if (
307 (chargingStation.inUnknownState() === true &&
308 commandName === RequestCommand.BOOT_NOTIFICATION) ||
309 (chargingStation.stationInfo?.ocppStrictCompliance === false &&
310 chargingStation.inUnknownState() === true) ||
311 chargingStation.inAcceptedState() === true ||
312 (chargingStation.inPendingState() === true &&
313 (params.triggerMessage === true || messageType === MessageType.CALL_RESULT_MESSAGE))
314 ) {
315 // eslint-disable-next-line @typescript-eslint/no-this-alias
316 const self = this;
317 // Send a message through wsConnection
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,
330 );
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 };
350
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 );
363 }
364 logger.error(
365 `${chargingStation.logPrefix()} Error occurred at ${OCPPServiceUtils.getMessageTypeString(
366 messageType,
367 )} command ${commandName} with PDU %j:`,
368 messagePayload,
369 error,
370 );
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,
402 );
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,
416 );
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(
428 ErrorType.GENERIC_ERROR,
429 `WebSocket closed or errored for buffered message id '${messageId}' with content '${messageToSend}'`,
430 commandName,
431 (messagePayload as JsonObject)?.details ?? Constants.EMPTY_FROZEN_OBJECT,
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
442 if (messageType !== MessageType.CALL_MESSAGE) {
443 return reject(ocppError);
444 }
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 });
453 }
454 throw new OCPPError(
455 ErrorType.SECURITY_ERROR,
456 `Cannot send command ${commandName} PDU when the charging station is in ${chargingStation.getRegistrationStatus()} state on the central server`,
457 commandName,
458 );
459 }
460
461 private buildMessageToSend(
462 chargingStation: ChargingStation,
463 messageId: string,
464 messagePayload: JsonType | OCPPError,
465 messageType: MessageType,
466 commandName: RequestCommand | IncomingRequestCommand,
467 responseCallback: ResponseCallback,
468 errorCallback: ErrorCallback,
469 ): string {
470 let messageToSend: string;
471 // Type of message
472 switch (messageType) {
473 // Request
474 case MessageType.CALL_MESSAGE:
475 // Build request
476 this.validateRequestPayload(chargingStation, commandName, messagePayload as JsonType);
477 chargingStation.requests.set(messageId, [
478 responseCallback,
479 errorCallback,
480 commandName,
481 messagePayload as JsonType,
482 ]);
483 messageToSend = JSON.stringify([
484 messageType,
485 messageId,
486 commandName,
487 messagePayload,
488 ] as OutgoingRequest);
489 break;
490 // Response
491 case MessageType.CALL_RESULT_MESSAGE:
492 // Build response
493 this.validateIncomingRequestResponsePayload(
494 chargingStation,
495 commandName,
496 messagePayload as JsonType,
497 );
498 messageToSend = JSON.stringify([messageType, messageId, messagePayload] as Response);
499 break;
500 // Error Message
501 case MessageType.CALL_ERROR_MESSAGE:
502 // Build Error Message
503 messageToSend = JSON.stringify([
504 messageType,
505 messageId,
506 (messagePayload as OCPPError)?.code ?? ErrorType.GENERIC_ERROR,
507 (messagePayload as OCPPError)?.message ?? '',
508 (messagePayload as OCPPError)?.details ?? { commandName },
509 ] as ErrorResponse);
510 break;
511 }
512 return messageToSend;
513 }
514
515 // eslint-disable-next-line @typescript-eslint/no-unused-vars
516 public abstract requestHandler<ReqType extends JsonType, ResType extends JsonType>(
517 chargingStation: ChargingStation,
518 commandName: RequestCommand,
519 // FIXME: should be ReqType
520 commandParams?: JsonType,
521 params?: RequestParams,
522 ): Promise<ResType>;
523 }