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