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