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