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