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