refactor: sensible default at meterValues custom value handling
[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 }
0b0ca54f 215 const validate = this.getJsonRequestValidateFunction<T>(commandName as RequestCommand);
9bf0ef23 216 payload = cloneObject<T>(payload);
1799761a 217 OCPPServiceUtils.convertDateToISOString<T>(payload);
b52c969d
JB
218 if (validate(payload)) {
219 return true;
220 }
221 logger.error(
45988780 222 `${chargingStation.logPrefix()} ${moduleName}.validateRequestPayload: Command '${commandName}' request PDU is invalid: %j`,
5edd8ba0 223 validate.errors,
b52c969d 224 );
e909d2a7 225 // OCPPError usage here is debatable: it's an error in the OCPP stack but not targeted to sendError().
b52c969d 226 throw new OCPPError(
9ff486f4 227 OCPPServiceUtils.ajvErrorsToErrorType(validate.errors),
b52c969d
JB
228 'Request PDU is invalid',
229 commandName,
4ed03b6e 230 JSON.stringify(validate.errors, undefined, 2),
b52c969d
JB
231 );
232 }
233
0b0ca54f
JB
234 private getJsonRequestValidateFunction<T extends JsonType>(commandName: RequestCommand) {
235 if (this.jsonValidateFunctions.has(commandName) === false) {
236 this.jsonValidateFunctions.set(
237 commandName,
238 this.ajv.compile<T>(this.jsonSchemas.get(commandName)!).bind(this),
239 );
240 }
241 return this.jsonValidateFunctions.get(commandName)!;
242 }
243
291b5ec8 244 private validateIncomingRequestResponsePayload<T extends JsonType>(
b3fc3ff5
JB
245 chargingStation: ChargingStation,
246 commandName: RequestCommand | IncomingRequestCommand,
5edd8ba0 247 payload: T,
b3fc3ff5 248 ): boolean {
5398cecf 249 if (chargingStation.stationInfo?.ocppStrictCompliance === false) {
b3fc3ff5
JB
250 return true;
251 }
252 if (
253 this.ocppResponseService.jsonIncomingRequestResponseSchemas.has(
5edd8ba0 254 commandName as IncomingRequestCommand,
b3fc3ff5
JB
255 ) === false
256 ) {
257 logger.warn(
5edd8ba0 258 `${chargingStation.logPrefix()} ${moduleName}.validateIncomingRequestResponsePayload: No JSON schema found for command '${commandName}' PDU validation`,
b3fc3ff5
JB
259 );
260 return true;
261 }
0b0ca54f 262 const validate = this.getJsonRequestResponseValidateFunction<T>(
ec0eebcc 263 commandName as IncomingRequestCommand,
0b0ca54f 264 );
9bf0ef23 265 payload = cloneObject<T>(payload);
b3fc3ff5
JB
266 OCPPServiceUtils.convertDateToISOString<T>(payload);
267 if (validate(payload)) {
268 return true;
269 }
270 logger.error(
02887891 271 `${chargingStation.logPrefix()} ${moduleName}.validateIncomingRequestResponsePayload: Command '${commandName}' reponse PDU is invalid: %j`,
5edd8ba0 272 validate.errors,
b3fc3ff5
JB
273 );
274 // OCPPError usage here is debatable: it's an error in the OCPP stack but not targeted to sendError().
275 throw new OCPPError(
9ff486f4 276 OCPPServiceUtils.ajvErrorsToErrorType(validate.errors),
b3fc3ff5
JB
277 'Response PDU is invalid',
278 commandName,
4ed03b6e 279 JSON.stringify(validate.errors, undefined, 2),
b3fc3ff5
JB
280 );
281 }
282
0b0ca54f
JB
283 private getJsonRequestResponseValidateFunction<T extends JsonType>(
284 commandName: IncomingRequestCommand,
285 ) {
286 if (
287 this.ocppResponseService.jsonIncomingRequestResponseValidateFunctions.has(commandName) ===
288 false
289 ) {
290 this.ocppResponseService.jsonIncomingRequestResponseValidateFunctions.set(
291 commandName,
292 this.ajv
293 .compile<T>(this.ocppResponseService.jsonIncomingRequestResponseSchemas.get(commandName)!)
294 .bind(this),
295 );
296 }
297 return this.ocppResponseService.jsonIncomingRequestResponseValidateFunctions.get(commandName)!;
298 }
299
e7aeea18 300 private async internalSendMessage(
08f130a0 301 chargingStation: ChargingStation,
e7aeea18 302 messageId: string,
5cc4b63b 303 messagePayload: JsonType | OCPPError,
e7aeea18 304 messageType: MessageType,
72092cfc 305 commandName: RequestCommand | IncomingRequestCommand,
7f3decca 306 params?: RequestParams,
e7aeea18 307 ): Promise<ResponseType> {
7b5dbe91 308 params = {
b9da1bc2 309 ...defaultRequestParams,
7b5dbe91
JB
310 ...params,
311 };
e7aeea18 312 if (
f7c2994d 313 (chargingStation.inUnknownState() === true &&
3a13fc92 314 commandName === RequestCommand.BOOT_NOTIFICATION) ||
5398cecf 315 (chargingStation.stationInfo?.ocppStrictCompliance === false &&
f7c2994d
JB
316 chargingStation.inUnknownState() === true) ||
317 chargingStation.inAcceptedState() === true ||
318 (chargingStation.inPendingState() === true &&
3a13fc92 319 (params.triggerMessage === true || messageType === MessageType.CALL_RESULT_MESSAGE))
e7aeea18 320 ) {
caad9d6b
JB
321 // eslint-disable-next-line @typescript-eslint/no-this-alias
322 const self = this;
323 // Send a message through wsConnection
9bf0ef23 324 return promiseWithTimeout(
474d4ffc 325 new Promise<ResponseType>((resolve, reject) => {
e8a92d57
JB
326 /**
327 * Function that will receive the request's response
328 *
329 * @param payload -
330 * @param requestPayload -
331 */
332 const responseCallback = (payload: JsonType, requestPayload: JsonType): void => {
5398cecf 333 if (chargingStation.stationInfo?.enableStatistics === true) {
e8a92d57
JB
334 chargingStation.performanceStatistics?.addRequestStatistic(
335 commandName,
5edd8ba0 336 MessageType.CALL_RESULT_MESSAGE,
e8a92d57
JB
337 );
338 }
339 // Handle the request's response
340 self.ocppResponseService
341 .responseHandler(
342 chargingStation,
343 commandName as RequestCommand,
344 payload,
5edd8ba0 345 requestPayload,
e8a92d57
JB
346 )
347 .then(() => {
348 resolve(payload);
349 })
350 .catch((error) => {
351 reject(error);
352 })
353 .finally(() => {
354 chargingStation.requests.delete(messageId);
355 });
356 };
357
358 /**
359 * Function that will receive the request's error response
360 *
361 * @param error -
362 * @param requestStatistic -
363 */
364 const errorCallback = (error: OCPPError, requestStatistic = true): void => {
5398cecf
JB
365 if (
366 requestStatistic === true &&
367 chargingStation.stationInfo?.enableStatistics === true
368 ) {
e8a92d57
JB
369 chargingStation.performanceStatistics?.addRequestStatistic(
370 commandName,
5edd8ba0 371 MessageType.CALL_ERROR_MESSAGE,
e8a92d57
JB
372 );
373 }
374 logger.error(
375 `${chargingStation.logPrefix()} Error occurred at ${OCPPServiceUtils.getMessageTypeString(
5edd8ba0 376 messageType,
e8a92d57
JB
377 )} command ${commandName} with PDU %j:`,
378 messagePayload,
5edd8ba0 379 error,
e8a92d57
JB
380 );
381 chargingStation.requests.delete(messageId);
382 reject(error);
383 };
384
5398cecf 385 if (chargingStation.stationInfo?.enableStatistics === true) {
551e477c 386 chargingStation.performanceStatistics?.addRequestStatistic(commandName, messageType);
764d2c91 387 }
e7aeea18 388 const messageToSend = this.buildMessageToSend(
08f130a0 389 chargingStation,
e7aeea18
JB
390 messageId,
391 messagePayload,
392 messageType,
393 commandName,
394 responseCallback,
5edd8ba0 395 errorCallback,
e7aeea18 396 );
1b821a64 397 let sendError = false;
e7aeea18 398 // Check if wsConnection opened
764d2c91
JB
399 const wsOpened = chargingStation.isWebSocketConnectionOpened() === true;
400 if (wsOpened) {
1431af78 401 const beginId = PerformanceStatistics.beginMeasure(commandName);
1b821a64 402 try {
72092cfc 403 chargingStation.wsConnection?.send(messageToSend);
18bf8274 404 logger.debug(
2cc5d5ec 405 `${chargingStation.logPrefix()} >> Command '${commandName}' sent ${OCPPServiceUtils.getMessageTypeString(
5edd8ba0
JB
406 messageType,
407 )} payload: ${messageToSend}`,
18bf8274 408 );
1b821a64 409 } catch (error) {
18bf8274 410 logger.error(
2cc5d5ec 411 `${chargingStation.logPrefix()} >> Command '${commandName}' failed to send ${OCPPServiceUtils.getMessageTypeString(
5edd8ba0 412 messageType,
18bf8274 413 )} payload: ${messageToSend}:`,
5edd8ba0 414 error,
18bf8274 415 );
1b821a64
JB
416 sendError = true;
417 }
1431af78 418 PerformanceStatistics.endMeasure(commandName, beginId);
1b821a64 419 }
764d2c91 420 const wsClosedOrErrored = !wsOpened || sendError === true;
7f3decca 421 if (wsClosedOrErrored && params?.skipBufferingOnError === false) {
1b821a64 422 // Buffer
08f130a0 423 chargingStation.bufferMessage(messageToSend);
1b821a64
JB
424 // Reject and keep request in the cache
425 return reject(
426 new OCPPError(
427 ErrorType.GENERIC_ERROR,
428 `WebSocket closed or errored for buffered message id '${messageId}' with content '${messageToSend}'`,
429 commandName,
2035255d 430 (messagePayload as JsonObject)?.details ?? Constants.EMPTY_FROZEN_OBJECT,
5edd8ba0 431 ),
1b821a64
JB
432 );
433 } else if (wsClosedOrErrored) {
e7aeea18
JB
434 const ocppError = new OCPPError(
435 ErrorType.GENERIC_ERROR,
1b821a64 436 `WebSocket closed or errored for non buffered message id '${messageId}' with content '${messageToSend}'`,
e7aeea18 437 commandName,
2035255d 438 (messagePayload as JsonObject)?.details ?? Constants.EMPTY_FROZEN_OBJECT,
e7aeea18 439 );
1b821a64
JB
440 // Reject response
441 if (messageType !== MessageType.CALL_MESSAGE) {
e7aeea18
JB
442 return reject(ocppError);
443 }
1b821a64 444 // Reject and remove request from the cache
a2d1c0f1 445 return errorCallback(ocppError, false);
caad9d6b 446 }
1b821a64 447 // Resolve response
e7aeea18 448 if (messageType !== MessageType.CALL_MESSAGE) {
e7aeea18
JB
449 return resolve(messagePayload);
450 }
e7aeea18 451 }),
d8b1fab1 452 OCPPConstants.OCPP_WEBSOCKET_TIMEOUT,
e7aeea18
JB
453 new OCPPError(
454 ErrorType.GENERIC_ERROR,
455 `Timeout for message id '${messageId}'`,
456 commandName,
2035255d 457 (messagePayload as JsonObject)?.details ?? Constants.EMPTY_FROZEN_OBJECT,
e7aeea18
JB
458 ),
459 () => {
08f130a0 460 messageType === MessageType.CALL_MESSAGE && chargingStation.requests.delete(messageId);
5edd8ba0 461 },
e7aeea18 462 );
caad9d6b 463 }
e7aeea18
JB
464 throw new OCPPError(
465 ErrorType.SECURITY_ERROR,
e3018bc4 466 `Cannot send command ${commandName} PDU when the charging station is in ${chargingStation.getRegistrationStatus()} state on the central server`,
5edd8ba0 467 commandName,
e7aeea18 468 );
c0560973
JB
469 }
470
e7aeea18 471 private buildMessageToSend(
08f130a0 472 chargingStation: ChargingStation,
e7aeea18 473 messageId: string,
5cc4b63b 474 messagePayload: JsonType | OCPPError,
e7aeea18 475 messageType: MessageType,
72092cfc
JB
476 commandName: RequestCommand | IncomingRequestCommand,
477 responseCallback: ResponseCallback,
5edd8ba0 478 errorCallback: ErrorCallback,
e7aeea18 479 ): string {
e7accadb
JB
480 let messageToSend: string;
481 // Type of message
482 switch (messageType) {
483 // Request
484 case MessageType.CALL_MESSAGE:
485 // Build request
291b5ec8 486 this.validateRequestPayload(chargingStation, commandName, messagePayload as JsonType);
08f130a0 487 chargingStation.requests.set(messageId, [
e7aeea18 488 responseCallback,
a2d1c0f1 489 errorCallback,
e7aeea18 490 commandName,
5cc4b63b 491 messagePayload as JsonType,
e7aeea18 492 ]);
b3ec7bc1
JB
493 messageToSend = JSON.stringify([
494 messageType,
495 messageId,
496 commandName,
497 messagePayload,
498 ] as OutgoingRequest);
e7accadb
JB
499 break;
500 // Response
501 case MessageType.CALL_RESULT_MESSAGE:
502 // Build response
02887891
JB
503 this.validateIncomingRequestResponsePayload(
504 chargingStation,
505 commandName,
291b5ec8 506 messagePayload as JsonType,
02887891 507 );
b3ec7bc1 508 messageToSend = JSON.stringify([messageType, messageId, messagePayload] as Response);
e7accadb
JB
509 break;
510 // Error Message
511 case MessageType.CALL_ERROR_MESSAGE:
512 // Build Error Message
e7aeea18
JB
513 messageToSend = JSON.stringify([
514 messageType,
515 messageId,
b3ec7bc1
JB
516 (messagePayload as OCPPError)?.code ?? ErrorType.GENERIC_ERROR,
517 (messagePayload as OCPPError)?.message ?? '',
518 (messagePayload as OCPPError)?.details ?? { commandName },
519 ] as ErrorResponse);
e7accadb
JB
520 break;
521 }
522 return messageToSend;
523 }
524
ef6fa3fb 525 // eslint-disable-next-line @typescript-eslint/no-unused-vars
e0b0ee21 526 public abstract requestHandler<ReqType extends JsonType, ResType extends JsonType>(
08f130a0 527 chargingStation: ChargingStation,
94a464f9 528 commandName: RequestCommand,
e1d9a0f4 529 // FIXME: should be ReqType
5cc4b63b 530 commandParams?: JsonType,
5edd8ba0 531 params?: RequestParams,
e0b0ee21 532 ): Promise<ResType>;
c0560973 533}