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