refactor: remove payloadSchemaValidation from template in favor of ocppStrictCompliance
[e-mobility-charging-stations-simulator.git] / src / charging-station / ocpp / OCPPRequestService.ts
1 import Ajv, { type JSONSchemaType } from 'ajv';
2 import ajvFormats from 'ajv-formats';
3
4 import { OCPPConstants } from './OCPPConstants';
5 import type { OCPPResponseService } from './OCPPResponseService';
6 import { OCPPServiceUtils } from './OCPPServiceUtils';
7 import type { ChargingStation } from '../../charging-station';
8 import { OCPPError } from '../../exception';
9 import { PerformanceStatistics } from '../../performance';
10 import {
11 type ErrorCallback,
12 type ErrorResponse,
13 ErrorType,
14 type IncomingRequestCommand,
15 type JsonObject,
16 type JsonType,
17 MessageType,
18 type OCPPVersion,
19 type OutgoingRequest,
20 RequestCommand,
21 type RequestParams,
22 type Response,
23 type ResponseCallback,
24 type ResponseType,
25 } from '../../types';
26 import {
27 Constants,
28 cloneObject,
29 handleSendMessageError,
30 logger,
31 promiseWithTimeout,
32 } from '../../utils';
33
34 const moduleName = 'OCPPRequestService';
35
36 const defaultRequestParams: RequestParams = {
37 skipBufferingOnError: false,
38 triggerMessage: false,
39 throwError: false,
40 };
41
42 export abstract class OCPPRequestService {
43 private static instance: OCPPRequestService | null = null;
44 private readonly version: OCPPVersion;
45 private readonly ajv: Ajv;
46 private readonly ocppResponseService: OCPPResponseService;
47 protected abstract jsonSchemas: Map<RequestCommand, JSONSchemaType<JsonObject>>;
48
49 protected constructor(version: OCPPVersion, ocppResponseService: OCPPResponseService) {
50 this.version = version;
51 this.ajv = new Ajv({
52 keywords: ['javaType'],
53 multipleOfPrecision: 2,
54 });
55 ajvFormats(this.ajv);
56 this.ocppResponseService = ocppResponseService;
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;
114 }
115
116 public static getInstance<T extends OCPPRequestService>(
117 this: new (ocppResponseService: OCPPResponseService) => T,
118 ocppResponseService: OCPPResponseService
119 ): T {
120 if (OCPPRequestService.instance === null) {
121 OCPPRequestService.instance = new this(ocppResponseService);
122 }
123 return OCPPRequestService.instance as T;
124 }
125
126 public async sendResponse(
127 chargingStation: ChargingStation,
128 messageId: string,
129 messagePayload: JsonType,
130 commandName: IncomingRequestCommand
131 ): Promise<ResponseType> {
132 try {
133 // Send response message
134 return await this.internalSendMessage(
135 chargingStation,
136 messageId,
137 messagePayload,
138 MessageType.CALL_RESULT_MESSAGE,
139 commandName
140 );
141 } catch (error) {
142 handleSendMessageError(chargingStation, commandName, error as Error, {
143 throwError: true,
144 });
145 }
146 }
147
148 public async sendError(
149 chargingStation: ChargingStation,
150 messageId: string,
151 ocppError: OCPPError,
152 commandName: RequestCommand | IncomingRequestCommand
153 ): Promise<ResponseType> {
154 try {
155 // Send error message
156 return await this.internalSendMessage(
157 chargingStation,
158 messageId,
159 ocppError,
160 MessageType.CALL_ERROR_MESSAGE,
161 commandName
162 );
163 } catch (error) {
164 handleSendMessageError(chargingStation, commandName, error as Error);
165 }
166 }
167
168 protected async sendMessage(
169 chargingStation: ChargingStation,
170 messageId: string,
171 messagePayload: JsonType,
172 commandName: RequestCommand,
173 params: RequestParams = defaultRequestParams
174 ): Promise<ResponseType> {
175 params = {
176 ...defaultRequestParams,
177 ...params,
178 };
179 try {
180 return await this.internalSendMessage(
181 chargingStation,
182 messageId,
183 messagePayload,
184 MessageType.CALL_MESSAGE,
185 commandName,
186 params
187 );
188 } catch (error) {
189 handleSendMessageError(chargingStation, commandName, error as Error, {
190 throwError: params.throwError,
191 });
192 }
193 }
194
195 private validateRequestPayload<T extends JsonObject>(
196 chargingStation: ChargingStation,
197 commandName: RequestCommand | IncomingRequestCommand,
198 payload: T
199 ): boolean {
200 if (chargingStation.getOcppStrictCompliance() === false) {
201 return true;
202 }
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 );
207 return true;
208 }
209 const validate = this.ajv.compile(this.jsonSchemas.get(commandName as RequestCommand));
210 payload = cloneObject<T>(payload);
211 OCPPServiceUtils.convertDateToISOString<T>(payload);
212 if (validate(payload)) {
213 return true;
214 }
215 logger.error(
216 `${chargingStation.logPrefix()} ${moduleName}.validateRequestPayload: Command '${commandName}' request PDU is invalid: %j`,
217 validate.errors
218 );
219 // OCPPError usage here is debatable: it's an error in the OCPP stack but not targeted to sendError().
220 throw new OCPPError(
221 OCPPServiceUtils.ajvErrorsToErrorType(validate.errors),
222 'Request PDU is invalid',
223 commandName,
224 JSON.stringify(validate.errors, null, 2)
225 );
226 }
227
228 private validateIncomingRequestResponsePayload<T extends JsonObject>(
229 chargingStation: ChargingStation,
230 commandName: RequestCommand | IncomingRequestCommand,
231 payload: T
232 ): boolean {
233 if (chargingStation.getOcppStrictCompliance() === false) {
234 return true;
235 }
236 if (
237 this.ocppResponseService.jsonIncomingRequestResponseSchemas.has(
238 commandName as IncomingRequestCommand
239 ) === false
240 ) {
241 logger.warn(
242 `${chargingStation.logPrefix()} ${moduleName}.validateIncomingRequestResponsePayload: No JSON schema found for command '${commandName}' PDU validation`
243 );
244 return true;
245 }
246 const validate = this.ajv.compile(
247 this.ocppResponseService.jsonIncomingRequestResponseSchemas.get(
248 commandName as IncomingRequestCommand
249 )
250 );
251 payload = cloneObject<T>(payload);
252 OCPPServiceUtils.convertDateToISOString<T>(payload);
253 if (validate(payload)) {
254 return true;
255 }
256 logger.error(
257 `${chargingStation.logPrefix()} ${moduleName}.validateIncomingRequestResponsePayload: Command '${commandName}' reponse PDU is invalid: %j`,
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
269 private async internalSendMessage(
270 chargingStation: ChargingStation,
271 messageId: string,
272 messagePayload: JsonType | OCPPError,
273 messageType: MessageType,
274 commandName: RequestCommand | IncomingRequestCommand,
275 params: RequestParams = defaultRequestParams
276 ): Promise<ResponseType> {
277 params = {
278 ...defaultRequestParams,
279 ...params,
280 };
281 if (
282 (chargingStation.inUnknownState() === true &&
283 commandName === RequestCommand.BOOT_NOTIFICATION) ||
284 (chargingStation.getOcppStrictCompliance() === false &&
285 chargingStation.inUnknownState() === true) ||
286 chargingStation.inAcceptedState() === true ||
287 (chargingStation.inPendingState() === true &&
288 (params.triggerMessage === true || messageType === MessageType.CALL_RESULT_MESSAGE))
289 ) {
290 // eslint-disable-next-line @typescript-eslint/no-this-alias
291 const self = this;
292 // Send a message through wsConnection
293 return promiseWithTimeout(
294 new Promise((resolve, reject) => {
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
351 if (chargingStation.getEnableStatistics() === true) {
352 chargingStation.performanceStatistics?.addRequestStatistic(commandName, messageType);
353 }
354 const messageToSend = this.buildMessageToSend(
355 chargingStation,
356 messageId,
357 messagePayload,
358 messageType,
359 commandName,
360 responseCallback,
361 errorCallback
362 );
363 let sendError = false;
364 // Check if wsConnection opened
365 const wsOpened = chargingStation.isWebSocketConnectionOpened() === true;
366 if (wsOpened) {
367 const beginId = PerformanceStatistics.beginMeasure(commandName);
368 try {
369 chargingStation.wsConnection?.send(messageToSend);
370 logger.debug(
371 `${chargingStation.logPrefix()} >> Command '${commandName}' sent ${OCPPServiceUtils.getMessageTypeString(
372 messageType
373 )} payload: ${messageToSend}`
374 );
375 } catch (error) {
376 logger.error(
377 `${chargingStation.logPrefix()} >> Command '${commandName}' failed to send ${OCPPServiceUtils.getMessageTypeString(
378 messageType
379 )} payload: ${messageToSend}:`,
380 error
381 );
382 sendError = true;
383 }
384 PerformanceStatistics.endMeasure(commandName, beginId);
385 }
386 const wsClosedOrErrored = !wsOpened || sendError === true;
387 if (wsClosedOrErrored && params.skipBufferingOnError === false) {
388 // Buffer
389 chargingStation.bufferMessage(messageToSend);
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,
396 (messagePayload as JsonObject)?.details ?? Constants.EMPTY_FREEZED_OBJECT
397 )
398 );
399 } else if (wsClosedOrErrored) {
400 const ocppError = new OCPPError(
401 ErrorType.GENERIC_ERROR,
402 `WebSocket closed or errored for non buffered message id '${messageId}' with content '${messageToSend}'`,
403 commandName,
404 (messagePayload as JsonObject)?.details ?? Constants.EMPTY_FREEZED_OBJECT
405 );
406 // Reject response
407 if (messageType !== MessageType.CALL_MESSAGE) {
408 return reject(ocppError);
409 }
410 // Reject and remove request from the cache
411 return errorCallback(ocppError, false);
412 }
413 // Resolve response
414 if (messageType !== MessageType.CALL_MESSAGE) {
415 return resolve(messagePayload);
416 }
417 }),
418 OCPPConstants.OCPP_WEBSOCKET_TIMEOUT,
419 new OCPPError(
420 ErrorType.GENERIC_ERROR,
421 `Timeout for message id '${messageId}'`,
422 commandName,
423 (messagePayload as JsonObject)?.details ?? Constants.EMPTY_FREEZED_OBJECT
424 ),
425 () => {
426 messageType === MessageType.CALL_MESSAGE && chargingStation.requests.delete(messageId);
427 }
428 );
429 }
430 throw new OCPPError(
431 ErrorType.SECURITY_ERROR,
432 `Cannot send command ${commandName} PDU when the charging station is in ${chargingStation.getRegistrationStatus()} state on the central server`,
433 commandName
434 );
435 }
436
437 private buildMessageToSend(
438 chargingStation: ChargingStation,
439 messageId: string,
440 messagePayload: JsonType | OCPPError,
441 messageType: MessageType,
442 commandName: RequestCommand | IncomingRequestCommand,
443 responseCallback: ResponseCallback,
444 errorCallback: ErrorCallback
445 ): string {
446 let messageToSend: string;
447 // Type of message
448 switch (messageType) {
449 // Request
450 case MessageType.CALL_MESSAGE:
451 // Build request
452 this.validateRequestPayload(chargingStation, commandName, messagePayload as JsonObject);
453 chargingStation.requests.set(messageId, [
454 responseCallback,
455 errorCallback,
456 commandName,
457 messagePayload as JsonType,
458 ]);
459 messageToSend = JSON.stringify([
460 messageType,
461 messageId,
462 commandName,
463 messagePayload,
464 ] as OutgoingRequest);
465 break;
466 // Response
467 case MessageType.CALL_RESULT_MESSAGE:
468 // Build response
469 this.validateIncomingRequestResponsePayload(
470 chargingStation,
471 commandName,
472 messagePayload as JsonObject
473 );
474 messageToSend = JSON.stringify([messageType, messageId, messagePayload] as Response);
475 break;
476 // Error Message
477 case MessageType.CALL_ERROR_MESSAGE:
478 // Build Error Message
479 messageToSend = JSON.stringify([
480 messageType,
481 messageId,
482 (messagePayload as OCPPError)?.code ?? ErrorType.GENERIC_ERROR,
483 (messagePayload as OCPPError)?.message ?? '',
484 (messagePayload as OCPPError)?.details ?? { commandName },
485 ] as ErrorResponse);
486 break;
487 }
488 return messageToSend;
489 }
490
491 // eslint-disable-next-line @typescript-eslint/no-unused-vars
492 public abstract requestHandler<ReqType extends JsonType, ResType extends JsonType>(
493 chargingStation: ChargingStation,
494 commandName: RequestCommand,
495 commandParams?: JsonType,
496 params?: RequestParams
497 ): Promise<ResType>;
498 }