refactor: cleanup control flow in OCPP stack
[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 {
27 Constants,
28 cloneObject,
29 handleSendMessageError,
30 isNullOrUndefined,
31 logger,
32 } from '../../utils';
33
34 const moduleName = 'OCPPRequestService';
35
36 const defaultRequestParams: RequestParams = {
37 skipBufferingOnError: false,
38 triggerMessage: false,
39 throwError: false,
40 };
41
42 export abstract class OCPPRequestService {
43 private static instance: OCPPRequestService | null = null;
44 private readonly version: OCPPVersion;
45 private readonly ajv: Ajv;
46 private readonly ocppResponseService: OCPPResponseService;
47 private readonly jsonValidateFunctions: Map<RequestCommand, ValidateFunction<JsonType>>;
48 protected abstract jsonSchemas: Map<RequestCommand, JSONSchemaType<JsonType>>;
49
50 protected constructor(version: OCPPVersion, ocppResponseService: OCPPResponseService) {
51 this.version = version;
52 this.ajv = new Ajv({
53 keywords: ['javaType'],
54 multipleOfPrecision: 2,
55 });
56 ajvFormats(this.ajv);
57 this.jsonValidateFunctions = new Map<RequestCommand, ValidateFunction<JsonType>>();
58 this.ocppResponseService = ocppResponseService;
59 this.requestHandler = this.requestHandler.bind(this) as <
60 // eslint-disable-next-line @typescript-eslint/no-unused-vars
61 ReqType extends JsonType,
62 ResType extends JsonType,
63 >(
64 chargingStation: ChargingStation,
65 commandName: RequestCommand,
66 commandParams?: JsonType,
67 params?: RequestParams,
68 ) => Promise<ResType>;
69 this.sendMessage = this.sendMessage.bind(this) as (
70 chargingStation: ChargingStation,
71 messageId: string,
72 messagePayload: JsonType,
73 commandName: RequestCommand,
74 params?: RequestParams,
75 ) => Promise<ResponseType>;
76 this.sendResponse = this.sendResponse.bind(this) as (
77 chargingStation: ChargingStation,
78 messageId: string,
79 messagePayload: JsonType,
80 commandName: IncomingRequestCommand,
81 ) => Promise<ResponseType>;
82 this.sendError = this.sendError.bind(this) as (
83 chargingStation: ChargingStation,
84 messageId: string,
85 ocppError: OCPPError,
86 commandName: RequestCommand | IncomingRequestCommand,
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,
94 params?: RequestParams,
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,
102 ) => string;
103 this.validateRequestPayload = this.validateRequestPayload.bind(this) as <T extends JsonType>(
104 chargingStation: ChargingStation,
105 commandName: RequestCommand | IncomingRequestCommand,
106 payload: T,
107 ) => boolean;
108 this.validateIncomingRequestResponsePayload = this.validateIncomingRequestResponsePayload.bind(
109 this,
110 ) as <T extends JsonType>(
111 chargingStation: ChargingStation,
112 commandName: RequestCommand | IncomingRequestCommand,
113 payload: T,
114 ) => boolean;
115 }
116
117 public static getInstance<T extends OCPPRequestService>(
118 this: new (ocppResponseService: OCPPResponseService) => T,
119 ocppResponseService: OCPPResponseService,
120 ): T {
121 if (OCPPRequestService.instance === null) {
122 OCPPRequestService.instance = new this(ocppResponseService);
123 }
124 return OCPPRequestService.instance as T;
125 }
126
127 public async sendResponse(
128 chargingStation: ChargingStation,
129 messageId: string,
130 messagePayload: JsonType,
131 commandName: IncomingRequestCommand,
132 ): Promise<ResponseType> {
133 try {
134 // Send response message
135 return await this.internalSendMessage(
136 chargingStation,
137 messageId,
138 messagePayload,
139 MessageType.CALL_RESULT_MESSAGE,
140 commandName,
141 );
142 } catch (error) {
143 handleSendMessageError(chargingStation, commandName, error as Error, {
144 throwError: true,
145 });
146 return null;
147 }
148 }
149
150 public async sendError(
151 chargingStation: ChargingStation,
152 messageId: string,
153 ocppError: OCPPError,
154 commandName: RequestCommand | IncomingRequestCommand,
155 ): Promise<ResponseType> {
156 try {
157 // Send error message
158 return await this.internalSendMessage(
159 chargingStation,
160 messageId,
161 ocppError,
162 MessageType.CALL_ERROR_MESSAGE,
163 commandName,
164 );
165 } catch (error) {
166 handleSendMessageError(chargingStation, commandName, error as Error);
167 return null;
168 }
169 }
170
171 protected async sendMessage(
172 chargingStation: ChargingStation,
173 messageId: string,
174 messagePayload: JsonType,
175 commandName: RequestCommand,
176 params?: RequestParams,
177 ): Promise<ResponseType> {
178 params = {
179 ...defaultRequestParams,
180 ...params,
181 };
182 try {
183 return await this.internalSendMessage(
184 chargingStation,
185 messageId,
186 messagePayload,
187 MessageType.CALL_MESSAGE,
188 commandName,
189 params,
190 );
191 } catch (error) {
192 handleSendMessageError(chargingStation, commandName, error as Error, {
193 throwError: params.throwError,
194 });
195 return null;
196 }
197 }
198
199 private validateRequestPayload<T extends JsonType>(
200 chargingStation: ChargingStation,
201 commandName: RequestCommand | IncomingRequestCommand,
202 payload: T,
203 ): boolean {
204 if (chargingStation.stationInfo?.ocppStrictCompliance === false) {
205 return true;
206 }
207 if (this.jsonSchemas.has(commandName as RequestCommand) === false) {
208 logger.warn(
209 `${chargingStation.logPrefix()} ${moduleName}.validateRequestPayload: No JSON schema found for command '${commandName}' PDU validation`,
210 );
211 return true;
212 }
213 const validate = this.getJsonRequestValidateFunction<T>(commandName as RequestCommand);
214 payload = cloneObject<T>(payload);
215 OCPPServiceUtils.convertDateToISOString<T>(payload);
216 if (validate(payload)) {
217 return true;
218 }
219 logger.error(
220 `${chargingStation.logPrefix()} ${moduleName}.validateRequestPayload: Command '${commandName}' request PDU is invalid: %j`,
221 validate.errors,
222 );
223 // OCPPError usage here is debatable: it's an error in the OCPP stack but not targeted to sendError().
224 throw new OCPPError(
225 OCPPServiceUtils.ajvErrorsToErrorType(validate.errors),
226 'Request PDU is invalid',
227 commandName,
228 JSON.stringify(validate.errors, undefined, 2),
229 );
230 }
231
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
242 private validateIncomingRequestResponsePayload<T extends JsonType>(
243 chargingStation: ChargingStation,
244 commandName: RequestCommand | IncomingRequestCommand,
245 payload: T,
246 ): boolean {
247 if (chargingStation.stationInfo?.ocppStrictCompliance === false) {
248 return true;
249 }
250 if (
251 this.ocppResponseService.jsonIncomingRequestResponseSchemas.has(
252 commandName as IncomingRequestCommand,
253 ) === false
254 ) {
255 logger.warn(
256 `${chargingStation.logPrefix()} ${moduleName}.validateIncomingRequestResponsePayload: No JSON schema found for command '${commandName}' PDU validation`,
257 );
258 return true;
259 }
260 const validate = this.getJsonRequestResponseValidateFunction<T>(
261 commandName as IncomingRequestCommand,
262 );
263 payload = cloneObject<T>(payload);
264 OCPPServiceUtils.convertDateToISOString<T>(payload);
265 if (validate(payload)) {
266 return true;
267 }
268 logger.error(
269 `${chargingStation.logPrefix()} ${moduleName}.validateIncomingRequestResponsePayload: Command '${commandName}' reponse PDU is invalid: %j`,
270 validate.errors,
271 );
272 // OCPPError usage here is debatable: it's an error in the OCPP stack but not targeted to sendError().
273 throw new OCPPError(
274 OCPPServiceUtils.ajvErrorsToErrorType(validate.errors),
275 'Response PDU is invalid',
276 commandName,
277 JSON.stringify(validate.errors, undefined, 2),
278 );
279 }
280
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
298 private async internalSendMessage(
299 chargingStation: ChargingStation,
300 messageId: string,
301 messagePayload: JsonType | OCPPError,
302 messageType: MessageType,
303 commandName: RequestCommand | IncomingRequestCommand,
304 params?: RequestParams,
305 ): Promise<ResponseType> {
306 params = {
307 ...defaultRequestParams,
308 ...params,
309 };
310 if (
311 (chargingStation.inUnknownState() === true &&
312 commandName === RequestCommand.BOOT_NOTIFICATION) ||
313 (chargingStation.stationInfo?.ocppStrictCompliance === false &&
314 chargingStation.inUnknownState() === true) ||
315 chargingStation.inAcceptedState() === true ||
316 (chargingStation.inPendingState() === true &&
317 (params.triggerMessage === true || messageType === MessageType.CALL_RESULT_MESSAGE))
318 ) {
319 // eslint-disable-next-line @typescript-eslint/no-this-alias
320 const self = this;
321 // Send a message through wsConnection
322 return new Promise<ResponseType>((resolve, reject) => {
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,
334 );
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 };
354
355 /**
356 * Function that will receive the request's error response
357 *
358 * @param ocppError -
359 * @param requestStatistic -
360 */
361 const errorCallback = (ocppError: OCPPError, requestStatistic = true): void => {
362 if (requestStatistic === true && chargingStation.stationInfo?.enableStatistics === true) {
363 chargingStation.performanceStatistics?.addRequestStatistic(
364 commandName,
365 MessageType.CALL_ERROR_MESSAGE,
366 );
367 }
368 logger.error(
369 `${chargingStation.logPrefix()} Error occurred at ${OCPPServiceUtils.getMessageTypeString(
370 messageType,
371 )} command ${commandName} with PDU %j:`,
372 messagePayload,
373 ocppError,
374 );
375 chargingStation.requests.delete(messageId);
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);
386 };
387
388 const bufferAndRejectWithOcppError = (ocppError: OCPPError): void => {
389 // Buffer
390 chargingStation.bufferMessage(messageToSend);
391 if (messageType === MessageType.CALL_MESSAGE) {
392 this.cacheRequestPromise(
393 chargingStation,
394 messageId,
395 messagePayload as JsonType,
396 commandName,
397 responseCallback,
398 errorCallback,
399 );
400 }
401 // Reject and keep request in the cache
402 return reject(ocppError);
403 };
404
405 if (chargingStation.stationInfo?.enableStatistics === true) {
406 chargingStation.performanceStatistics?.addRequestStatistic(commandName, messageType);
407 }
408 const messageToSend = this.buildMessageToSend(
409 chargingStation,
410 messageId,
411 messagePayload,
412 messageType,
413 commandName,
414 );
415 // Check if wsConnection opened
416 if (chargingStation.isWebSocketConnectionOpened() === true) {
417 const beginId = PerformanceStatistics.beginMeasure(commandName);
418 const sendTimeout = setTimeout(() => {
419 return rejectWithOcppError(
420 new OCPPError(
421 ErrorType.GENERIC_ERROR,
422 `Timeout for message id '${messageId}'`,
423 commandName,
424 (messagePayload as JsonObject)?.details ?? Constants.EMPTY_FROZEN_OBJECT,
425 ),
426 );
427 }, OCPPConstants.OCPP_WEBSOCKET_TIMEOUT);
428 chargingStation.wsConnection?.send(messageToSend, (error?: Error) => {
429 PerformanceStatistics.endMeasure(commandName, beginId);
430 clearTimeout(sendTimeout);
431 if (isNullOrUndefined(error)) {
432 logger.debug(
433 `${chargingStation.logPrefix()} >> Command '${commandName}' sent ${OCPPServiceUtils.getMessageTypeString(
434 messageType,
435 )} payload: ${messageToSend}`,
436 );
437 if (messageType === MessageType.CALL_MESSAGE) {
438 this.cacheRequestPromise(
439 chargingStation,
440 messageId,
441 messagePayload as JsonType,
442 commandName,
443 responseCallback,
444 errorCallback,
445 );
446 } else {
447 // Resolve response
448 return resolve(messagePayload);
449 }
450 } else if (error) {
451 const ocppError = new OCPPError(
452 ErrorType.GENERIC_ERROR,
453 `WebSocket errored for ${
454 params?.skipBufferingOnError === false ? '' : 'non '
455 }buffered message id '${messageId}' with content '${messageToSend}'`,
456 commandName,
457 { name: error.name, message: error.message, stack: error.stack },
458 );
459 if (params?.skipBufferingOnError === false) {
460 return bufferAndRejectWithOcppError(ocppError);
461 }
462 return rejectWithOcppError(ocppError);
463 }
464 });
465 } else {
466 const ocppError = new OCPPError(
467 ErrorType.GENERIC_ERROR,
468 `WebSocket closed for ${
469 params?.skipBufferingOnError === false ? '' : 'non '
470 }buffered message id '${messageId}' with content '${messageToSend}'`,
471 commandName,
472 (messagePayload as JsonObject)?.details ?? Constants.EMPTY_FROZEN_OBJECT,
473 );
474 if (params?.skipBufferingOnError === false) {
475 return bufferAndRejectWithOcppError(ocppError);
476 }
477 return rejectWithOcppError(ocppError);
478 }
479 });
480 }
481 throw new OCPPError(
482 ErrorType.SECURITY_ERROR,
483 `Cannot send command ${commandName} PDU when the charging station is in ${chargingStation.getRegistrationStatus()} state on the central server`,
484 commandName,
485 );
486 }
487
488 private buildMessageToSend(
489 chargingStation: ChargingStation,
490 messageId: string,
491 messagePayload: JsonType | OCPPError,
492 messageType: MessageType,
493 commandName: RequestCommand | IncomingRequestCommand,
494 ): string {
495 let messageToSend: string;
496 // Type of message
497 switch (messageType) {
498 // Request
499 case MessageType.CALL_MESSAGE:
500 // Build request
501 this.validateRequestPayload(chargingStation, commandName, messagePayload as JsonType);
502 messageToSend = JSON.stringify([
503 messageType,
504 messageId,
505 commandName,
506 messagePayload,
507 ] as OutgoingRequest);
508 break;
509 // Response
510 case MessageType.CALL_RESULT_MESSAGE:
511 // Build response
512 this.validateIncomingRequestResponsePayload(
513 chargingStation,
514 commandName,
515 messagePayload as JsonType,
516 );
517 messageToSend = JSON.stringify([messageType, messageId, messagePayload] as Response);
518 break;
519 // Error Message
520 case MessageType.CALL_ERROR_MESSAGE:
521 // Build Error Message
522 messageToSend = JSON.stringify([
523 messageType,
524 messageId,
525 (messagePayload as OCPPError)?.code ?? ErrorType.GENERIC_ERROR,
526 (messagePayload as OCPPError)?.message ?? '',
527 (messagePayload as OCPPError)?.details ?? { commandName },
528 ] as ErrorResponse);
529 break;
530 }
531 return messageToSend;
532 }
533
534 private cacheRequestPromise(
535 chargingStation: ChargingStation,
536 messageId: string,
537 messagePayload: JsonType,
538 commandName: RequestCommand | IncomingRequestCommand,
539 responseCallback: ResponseCallback,
540 errorCallback: ErrorCallback,
541 ): void {
542 chargingStation.requests.set(messageId, [
543 responseCallback,
544 errorCallback,
545 commandName,
546 messagePayload,
547 ]);
548 }
549
550 // eslint-disable-next-line @typescript-eslint/no-unused-vars
551 public abstract requestHandler<ReqType extends JsonType, ResType extends JsonType>(
552 chargingStation: ChargingStation,
553 commandName: RequestCommand,
554 // FIXME: should be ReqType
555 commandParams?: JsonType,
556 params?: RequestParams,
557 ): Promise<ResType>;
558 }