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