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