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