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