refactor: cleanup method namaspace
[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, type OCPPResponseService, OCPPServiceUtils } from './internal';
5 import type { ChargingStation } from '../../charging-station';
6 import { OCPPError } from '../../exception';
7 import { PerformanceStatistics } from '../../performance';
8 import {
9 type EmptyObject,
10 type ErrorCallback,
11 type ErrorResponse,
12 ErrorType,
13 type HandleErrorParams,
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 { Constants, Utils, logger } from '../../utils';
27
28 const moduleName = 'OCPPRequestService';
29
30 export abstract class OCPPRequestService {
31 private static instance: OCPPRequestService | null = null;
32 private readonly version: OCPPVersion;
33 private readonly ajv: Ajv;
34 private readonly ocppResponseService: OCPPResponseService;
35 protected abstract jsonSchemas: Map<RequestCommand, JSONSchemaType<JsonObject>>;
36
37 protected constructor(version: OCPPVersion, ocppResponseService: OCPPResponseService) {
38 this.version = version;
39 this.ajv = new Ajv({
40 keywords: ['javaType'],
41 multipleOfPrecision: 2,
42 });
43 ajvFormats(this.ajv);
44 this.ocppResponseService = ocppResponseService;
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;
102 }
103
104 public static getInstance<T extends OCPPRequestService>(
105 this: new (ocppResponseService: OCPPResponseService) => T,
106 ocppResponseService: OCPPResponseService
107 ): T {
108 if (OCPPRequestService.instance === null) {
109 OCPPRequestService.instance = new this(ocppResponseService);
110 }
111 return OCPPRequestService.instance as T;
112 }
113
114 public async sendResponse(
115 chargingStation: ChargingStation,
116 messageId: string,
117 messagePayload: JsonType,
118 commandName: IncomingRequestCommand
119 ): Promise<ResponseType> {
120 try {
121 // Send response message
122 return await this.internalSendMessage(
123 chargingStation,
124 messageId,
125 messagePayload,
126 MessageType.CALL_RESULT_MESSAGE,
127 commandName
128 );
129 } catch (error) {
130 this.handleSendMessageError(chargingStation, commandName, error as Error, {
131 throwError: true,
132 });
133 }
134 }
135
136 public async sendError(
137 chargingStation: ChargingStation,
138 messageId: string,
139 ocppError: OCPPError,
140 commandName: RequestCommand | IncomingRequestCommand
141 ): Promise<ResponseType> {
142 try {
143 // Send error message
144 return await this.internalSendMessage(
145 chargingStation,
146 messageId,
147 ocppError,
148 MessageType.CALL_ERROR_MESSAGE,
149 commandName
150 );
151 } catch (error) {
152 this.handleSendMessageError(chargingStation, commandName, error as Error);
153 }
154 }
155
156 protected async sendMessage(
157 chargingStation: ChargingStation,
158 messageId: string,
159 messagePayload: JsonType,
160 commandName: RequestCommand,
161 params: RequestParams = {
162 skipBufferingOnError: false,
163 triggerMessage: false,
164 throwError: false,
165 }
166 ): Promise<ResponseType> {
167 try {
168 return await this.internalSendMessage(
169 chargingStation,
170 messageId,
171 messagePayload,
172 MessageType.CALL_MESSAGE,
173 commandName,
174 params
175 );
176 } catch (error) {
177 this.handleSendMessageError(chargingStation, commandName, error as Error, {
178 throwError: params.throwError,
179 });
180 }
181 }
182
183 private validateRequestPayload<T extends JsonObject>(
184 chargingStation: ChargingStation,
185 commandName: RequestCommand | IncomingRequestCommand,
186 payload: T
187 ): boolean {
188 if (chargingStation.getPayloadSchemaValidation() === false) {
189 return true;
190 }
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 );
195 return true;
196 }
197 const validate = this.ajv.compile(this.jsonSchemas.get(commandName as RequestCommand));
198 payload = Utils.cloneObject<T>(payload);
199 OCPPServiceUtils.convertDateToISOString<T>(payload);
200 if (validate(payload)) {
201 return true;
202 }
203 logger.error(
204 `${chargingStation.logPrefix()} ${moduleName}.validateRequestPayload: Command '${commandName}' request PDU is invalid: %j`,
205 validate.errors
206 );
207 // OCPPError usage here is debatable: it's an error in the OCPP stack but not targeted to sendError().
208 throw new OCPPError(
209 OCPPServiceUtils.ajvErrorsToErrorType(validate.errors),
210 'Request PDU is invalid',
211 commandName,
212 JSON.stringify(validate.errors, null, 2)
213 );
214 }
215
216 private validateIncomingRequestResponsePayload<T extends JsonObject>(
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(
230 `${chargingStation.logPrefix()} ${moduleName}.validateIncomingRequestResponsePayload: No JSON schema found for command '${commandName}' PDU validation`
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(
245 `${chargingStation.logPrefix()} ${moduleName}.validateIncomingRequestResponsePayload: Command '${commandName}' reponse PDU is invalid: %j`,
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
257 private async internalSendMessage(
258 chargingStation: ChargingStation,
259 messageId: string,
260 messagePayload: JsonType | OCPPError,
261 messageType: MessageType,
262 commandName: RequestCommand | IncomingRequestCommand,
263 params: RequestParams = {
264 skipBufferingOnError: false,
265 triggerMessage: false,
266 }
267 ): Promise<ResponseType> {
268 if (
269 (chargingStation.inUnknownState() === true &&
270 commandName === RequestCommand.BOOT_NOTIFICATION) ||
271 (chargingStation.getOcppStrictCompliance() === false &&
272 chargingStation.inUnknownState() === true) ||
273 chargingStation.inAcceptedState() === true ||
274 (chargingStation.inPendingState() === true &&
275 (params.triggerMessage === true || messageType === MessageType.CALL_RESULT_MESSAGE))
276 ) {
277 // eslint-disable-next-line @typescript-eslint/no-this-alias
278 const self = this;
279 // Send a message through wsConnection
280 return Utils.promiseWithTimeout(
281 new Promise((resolve, reject) => {
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
338 if (chargingStation.getEnableStatistics() === true) {
339 chargingStation.performanceStatistics?.addRequestStatistic(commandName, messageType);
340 }
341 const messageToSend = this.buildMessageToSend(
342 chargingStation,
343 messageId,
344 messagePayload,
345 messageType,
346 commandName,
347 responseCallback,
348 errorCallback
349 );
350 let sendError = false;
351 // Check if wsConnection opened
352 const wsOpened = chargingStation.isWebSocketConnectionOpened() === true;
353 if (wsOpened) {
354 const beginId = PerformanceStatistics.beginMeasure(commandName);
355 try {
356 chargingStation.wsConnection?.send(messageToSend);
357 logger.debug(
358 `${chargingStation.logPrefix()} >> Command '${commandName}' sent ${OCPPServiceUtils.getMessageTypeString(
359 messageType
360 )} payload: ${messageToSend}`
361 );
362 } catch (error) {
363 logger.error(
364 `${chargingStation.logPrefix()} >> Command '${commandName}' failed to send ${OCPPServiceUtils.getMessageTypeString(
365 messageType
366 )} payload: ${messageToSend}:`,
367 error
368 );
369 sendError = true;
370 }
371 PerformanceStatistics.endMeasure(commandName, beginId);
372 }
373 const wsClosedOrErrored = !wsOpened || sendError === true;
374 if (wsClosedOrErrored && params.skipBufferingOnError === false) {
375 // Buffer
376 chargingStation.bufferMessage(messageToSend);
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,
383 (messagePayload as JsonObject)?.details ?? Constants.EMPTY_FREEZED_OBJECT
384 )
385 );
386 } else if (wsClosedOrErrored) {
387 const ocppError = new OCPPError(
388 ErrorType.GENERIC_ERROR,
389 `WebSocket closed or errored for non buffered message id '${messageId}' with content '${messageToSend}'`,
390 commandName,
391 (messagePayload as JsonObject)?.details ?? Constants.EMPTY_FREEZED_OBJECT
392 );
393 // Reject response
394 if (messageType !== MessageType.CALL_MESSAGE) {
395 return reject(ocppError);
396 }
397 // Reject and remove request from the cache
398 return errorCallback(ocppError, false);
399 }
400 // Resolve response
401 if (messageType !== MessageType.CALL_MESSAGE) {
402 return resolve(messagePayload);
403 }
404 }),
405 OCPPConstants.OCPP_WEBSOCKET_TIMEOUT,
406 new OCPPError(
407 ErrorType.GENERIC_ERROR,
408 `Timeout for message id '${messageId}'`,
409 commandName,
410 (messagePayload as JsonObject)?.details ?? Constants.EMPTY_FREEZED_OBJECT
411 ),
412 () => {
413 messageType === MessageType.CALL_MESSAGE && chargingStation.requests.delete(messageId);
414 }
415 );
416 }
417 throw new OCPPError(
418 ErrorType.SECURITY_ERROR,
419 `Cannot send command ${commandName} PDU when the charging station is in ${chargingStation.getRegistrationStatus()} state on the central server`,
420 commandName
421 );
422 }
423
424 private buildMessageToSend(
425 chargingStation: ChargingStation,
426 messageId: string,
427 messagePayload: JsonType | OCPPError,
428 messageType: MessageType,
429 commandName: RequestCommand | IncomingRequestCommand,
430 responseCallback: ResponseCallback,
431 errorCallback: ErrorCallback
432 ): string {
433 let messageToSend: string;
434 // Type of message
435 switch (messageType) {
436 // Request
437 case MessageType.CALL_MESSAGE:
438 // Build request
439 this.validateRequestPayload(chargingStation, commandName, messagePayload as JsonObject);
440 chargingStation.requests.set(messageId, [
441 responseCallback,
442 errorCallback,
443 commandName,
444 messagePayload as JsonType,
445 ]);
446 messageToSend = JSON.stringify([
447 messageType,
448 messageId,
449 commandName,
450 messagePayload,
451 ] as OutgoingRequest);
452 break;
453 // Response
454 case MessageType.CALL_RESULT_MESSAGE:
455 // Build response
456 this.validateIncomingRequestResponsePayload(
457 chargingStation,
458 commandName,
459 messagePayload as JsonObject
460 );
461 messageToSend = JSON.stringify([messageType, messageId, messagePayload] as Response);
462 break;
463 // Error Message
464 case MessageType.CALL_ERROR_MESSAGE:
465 // Build Error Message
466 messageToSend = JSON.stringify([
467 messageType,
468 messageId,
469 (messagePayload as OCPPError)?.code ?? ErrorType.GENERIC_ERROR,
470 (messagePayload as OCPPError)?.message ?? '',
471 (messagePayload as OCPPError)?.details ?? { commandName },
472 ] as ErrorResponse);
473 break;
474 }
475 return messageToSend;
476 }
477
478 private handleSendMessageError(
479 chargingStation: ChargingStation,
480 commandName: RequestCommand | IncomingRequestCommand,
481 error: Error,
482 params: HandleErrorParams<EmptyObject> = { throwError: false }
483 ): void {
484 logger.error(`${chargingStation.logPrefix()} Request command '${commandName}' error:`, error);
485 if (params?.throwError === true) {
486 throw error;
487 }
488 }
489
490 // eslint-disable-next-line @typescript-eslint/no-unused-vars
491 public abstract requestHandler<ReqType extends JsonType, ResType extends JsonType>(
492 chargingStation: ChargingStation,
493 commandName: RequestCommand,
494 commandParams?: JsonType,
495 params?: RequestParams
496 ): Promise<ResType>;
497 }