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