f1ea87112dcfbfaed5fb261418e0cd5d396df5e3
[e-mobility-charging-stations-simulator.git] / src / charging-station / ocpp / OCPPRequestService.ts
1 import Ajv, { type JSONSchemaType, type ValidateFunction } 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 isNullOrUndefined,
31 logger,
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 private readonly jsonValidateFunctions: Map<RequestCommand, ValidateFunction<JsonType>>;
48 protected abstract jsonSchemas: Map<RequestCommand, JSONSchemaType<JsonType>>;
49
50 protected constructor(version: OCPPVersion, ocppResponseService: OCPPResponseService) {
51 this.version = version;
52 this.ajv = new Ajv({
53 keywords: ['javaType'],
54 multipleOfPrecision: 2,
55 });
56 ajvFormats(this.ajv);
57 this.jsonValidateFunctions = new Map<RequestCommand, ValidateFunction<JsonType>>();
58 this.ocppResponseService = ocppResponseService;
59 this.requestHandler = this.requestHandler.bind(this) as <
60 // eslint-disable-next-line @typescript-eslint/no-unused-vars
61 ReqType extends JsonType,
62 ResType extends JsonType,
63 >(
64 chargingStation: ChargingStation,
65 commandName: RequestCommand,
66 commandParams?: JsonType,
67 params?: RequestParams,
68 ) => Promise<ResType>;
69 this.sendMessage = this.sendMessage.bind(this) as (
70 chargingStation: ChargingStation,
71 messageId: string,
72 messagePayload: JsonType,
73 commandName: RequestCommand,
74 params?: RequestParams,
75 ) => Promise<ResponseType>;
76 this.sendResponse = this.sendResponse.bind(this) as (
77 chargingStation: ChargingStation,
78 messageId: string,
79 messagePayload: JsonType,
80 commandName: IncomingRequestCommand,
81 ) => Promise<ResponseType>;
82 this.sendError = this.sendError.bind(this) as (
83 chargingStation: ChargingStation,
84 messageId: string,
85 ocppError: OCPPError,
86 commandName: RequestCommand | IncomingRequestCommand,
87 ) => Promise<ResponseType>;
88 this.internalSendMessage = this.internalSendMessage.bind(this) as (
89 chargingStation: ChargingStation,
90 messageId: string,
91 messagePayload: JsonType | OCPPError,
92 messageType: MessageType,
93 commandName: RequestCommand | IncomingRequestCommand,
94 params?: RequestParams,
95 ) => Promise<ResponseType>;
96 this.buildMessageToSend = this.buildMessageToSend.bind(this) as (
97 chargingStation: ChargingStation,
98 messageId: string,
99 messagePayload: JsonType | OCPPError,
100 messageType: MessageType,
101 commandName: RequestCommand | IncomingRequestCommand,
102 ) => string;
103 this.validateRequestPayload = this.validateRequestPayload.bind(this) as <T extends JsonType>(
104 chargingStation: ChargingStation,
105 commandName: RequestCommand | IncomingRequestCommand,
106 payload: T,
107 ) => boolean;
108 this.validateIncomingRequestResponsePayload = this.validateIncomingRequestResponsePayload.bind(
109 this,
110 ) as <T extends JsonType>(
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 JsonType>(
200 chargingStation: ChargingStation,
201 commandName: RequestCommand | IncomingRequestCommand,
202 payload: T,
203 ): boolean {
204 if (chargingStation.stationInfo?.ocppStrictCompliance === 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.getJsonRequestValidateFunction<T>(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 getJsonRequestValidateFunction<T extends JsonType>(commandName: RequestCommand) {
233 if (this.jsonValidateFunctions.has(commandName) === false) {
234 this.jsonValidateFunctions.set(
235 commandName,
236 this.ajv.compile<T>(this.jsonSchemas.get(commandName)!).bind(this),
237 );
238 }
239 return this.jsonValidateFunctions.get(commandName)!;
240 }
241
242 private validateIncomingRequestResponsePayload<T extends JsonType>(
243 chargingStation: ChargingStation,
244 commandName: RequestCommand | IncomingRequestCommand,
245 payload: T,
246 ): boolean {
247 if (chargingStation.stationInfo?.ocppStrictCompliance === false) {
248 return true;
249 }
250 if (
251 this.ocppResponseService.jsonIncomingRequestResponseSchemas.has(
252 commandName as IncomingRequestCommand,
253 ) === false
254 ) {
255 logger.warn(
256 `${chargingStation.logPrefix()} ${moduleName}.validateIncomingRequestResponsePayload: No JSON schema found for command '${commandName}' PDU validation`,
257 );
258 return true;
259 }
260 const validate = this.getJsonRequestResponseValidateFunction<T>(
261 commandName as IncomingRequestCommand,
262 );
263 payload = cloneObject<T>(payload);
264 OCPPServiceUtils.convertDateToISOString<T>(payload);
265 if (validate(payload)) {
266 return true;
267 }
268 logger.error(
269 `${chargingStation.logPrefix()} ${moduleName}.validateIncomingRequestResponsePayload: Command '${commandName}' reponse PDU is invalid: %j`,
270 validate.errors,
271 );
272 // OCPPError usage here is debatable: it's an error in the OCPP stack but not targeted to sendError().
273 throw new OCPPError(
274 OCPPServiceUtils.ajvErrorsToErrorType(validate.errors),
275 'Response PDU is invalid',
276 commandName,
277 JSON.stringify(validate.errors, undefined, 2),
278 );
279 }
280
281 private getJsonRequestResponseValidateFunction<T extends JsonType>(
282 commandName: IncomingRequestCommand,
283 ) {
284 if (
285 this.ocppResponseService.jsonIncomingRequestResponseValidateFunctions.has(commandName) ===
286 false
287 ) {
288 this.ocppResponseService.jsonIncomingRequestResponseValidateFunctions.set(
289 commandName,
290 this.ajv
291 .compile<T>(this.ocppResponseService.jsonIncomingRequestResponseSchemas.get(commandName)!)
292 .bind(this),
293 );
294 }
295 return this.ocppResponseService.jsonIncomingRequestResponseValidateFunctions.get(commandName)!;
296 }
297
298 private async internalSendMessage(
299 chargingStation: ChargingStation,
300 messageId: string,
301 messagePayload: JsonType | OCPPError,
302 messageType: MessageType,
303 commandName: RequestCommand | IncomingRequestCommand,
304 params?: RequestParams,
305 ): Promise<ResponseType> {
306 params = {
307 ...defaultRequestParams,
308 ...params,
309 };
310 if (
311 (chargingStation.inUnknownState() === true &&
312 commandName === RequestCommand.BOOT_NOTIFICATION) ||
313 (chargingStation.stationInfo?.ocppStrictCompliance === false &&
314 chargingStation.inUnknownState() === true) ||
315 chargingStation.inAcceptedState() === true ||
316 (chargingStation.inPendingState() === true &&
317 (params.triggerMessage === true || messageType === MessageType.CALL_RESULT_MESSAGE))
318 ) {
319 // eslint-disable-next-line @typescript-eslint/no-this-alias
320 const self = this;
321 // Send a message through wsConnection
322 return new Promise<ResponseType>((resolve, reject) => {
323 /**
324 * Function that will receive the request's response
325 *
326 * @param payload -
327 * @param requestPayload -
328 */
329 const responseCallback = (payload: JsonType, requestPayload: JsonType): void => {
330 if (chargingStation.stationInfo?.enableStatistics === true) {
331 chargingStation.performanceStatistics?.addRequestStatistic(
332 commandName,
333 MessageType.CALL_RESULT_MESSAGE,
334 );
335 }
336 // Handle the request's response
337 self.ocppResponseService
338 .responseHandler(
339 chargingStation,
340 commandName as RequestCommand,
341 payload,
342 requestPayload,
343 )
344 .then(() => {
345 resolve(payload);
346 })
347 .catch((error) => {
348 reject(error);
349 })
350 .finally(() => {
351 chargingStation.requests.delete(messageId);
352 });
353 };
354
355 /**
356 * Function that will receive the request's error response
357 *
358 * @param ocppError -
359 * @param requestStatistic -
360 */
361 const errorCallback = (ocppError: OCPPError, requestStatistic = true): void => {
362 if (requestStatistic === true && chargingStation.stationInfo?.enableStatistics === true) {
363 chargingStation.performanceStatistics?.addRequestStatistic(
364 commandName,
365 MessageType.CALL_ERROR_MESSAGE,
366 );
367 }
368 logger.error(
369 `${chargingStation.logPrefix()} Error occurred at ${OCPPServiceUtils.getMessageTypeString(
370 messageType,
371 )} command ${commandName} with PDU %j:`,
372 messagePayload,
373 ocppError,
374 );
375 chargingStation.requests.delete(messageId);
376 reject(ocppError);
377 };
378
379 const rejectWithOcppError = (ocppError: OCPPError): void => {
380 // Reject response
381 if (messageType !== MessageType.CALL_MESSAGE) {
382 return reject(ocppError);
383 }
384 // Reject and remove request from the cache
385 return errorCallback(ocppError, false);
386 };
387
388 const bufferAndRejectWithOcppError = (ocppError: OCPPError): void => {
389 // Buffer
390 chargingStation.bufferMessage(messageToSend);
391 if (messageType === MessageType.CALL_MESSAGE) {
392 this.cacheRequestPromise(
393 chargingStation,
394 messageId,
395 messagePayload as JsonType,
396 commandName,
397 responseCallback,
398 errorCallback,
399 );
400 }
401 // Reject and keep request in the cache
402 return reject(ocppError);
403 };
404
405 if (chargingStation.stationInfo?.enableStatistics === true) {
406 chargingStation.performanceStatistics?.addRequestStatistic(commandName, messageType);
407 }
408 const messageToSend = this.buildMessageToSend(
409 chargingStation,
410 messageId,
411 messagePayload,
412 messageType,
413 commandName,
414 );
415 // Check if wsConnection opened
416 if (chargingStation.isWebSocketConnectionOpened() === true) {
417 const beginId = PerformanceStatistics.beginMeasure(commandName);
418 const sendTimeout = setTimeout(() => {
419 return rejectWithOcppError(
420 new OCPPError(
421 ErrorType.GENERIC_ERROR,
422 `Timeout for message id '${messageId}'`,
423 commandName,
424 (messagePayload as JsonObject)?.details ?? Constants.EMPTY_FROZEN_OBJECT,
425 ),
426 );
427 }, OCPPConstants.OCPP_WEBSOCKET_TIMEOUT);
428 chargingStation.wsConnection?.send(messageToSend, (error?: Error) => {
429 PerformanceStatistics.endMeasure(commandName, beginId);
430 clearTimeout(sendTimeout);
431 if (isNullOrUndefined(error)) {
432 logger.debug(
433 `${chargingStation.logPrefix()} >> Command '${commandName}' sent ${OCPPServiceUtils.getMessageTypeString(
434 messageType,
435 )} payload: ${messageToSend}`,
436 );
437 if (messageType === MessageType.CALL_MESSAGE) {
438 this.cacheRequestPromise(
439 chargingStation,
440 messageId,
441 messagePayload as JsonType,
442 commandName,
443 responseCallback,
444 errorCallback,
445 );
446 }
447 // Resolve response
448 if (messageType !== MessageType.CALL_MESSAGE) {
449 return resolve(messagePayload);
450 }
451 } else if (error) {
452 const ocppError = new OCPPError(
453 ErrorType.GENERIC_ERROR,
454 `WebSocket errored for ${
455 params?.skipBufferingOnError === false ? '' : 'non '
456 }buffered message id '${messageId}' with content '${messageToSend}'`,
457 commandName,
458 { name: error.name, message: error.message, stack: error.stack },
459 );
460 if (params?.skipBufferingOnError === false) {
461 return bufferAndRejectWithOcppError(ocppError);
462 }
463 return rejectWithOcppError(ocppError);
464 }
465 });
466 } else {
467 const ocppError = new OCPPError(
468 ErrorType.GENERIC_ERROR,
469 `WebSocket closed for ${
470 params?.skipBufferingOnError === false ? '' : 'non '
471 }buffered message id '${messageId}' with content '${messageToSend}'`,
472 commandName,
473 (messagePayload as JsonObject)?.details ?? Constants.EMPTY_FROZEN_OBJECT,
474 );
475 if (params?.skipBufferingOnError === false) {
476 return bufferAndRejectWithOcppError(ocppError);
477 }
478 return rejectWithOcppError(ocppError);
479 }
480 });
481 }
482 throw new OCPPError(
483 ErrorType.SECURITY_ERROR,
484 `Cannot send command ${commandName} PDU when the charging station is in ${chargingStation.getRegistrationStatus()} state on the central server`,
485 commandName,
486 );
487 }
488
489 private buildMessageToSend(
490 chargingStation: ChargingStation,
491 messageId: string,
492 messagePayload: JsonType | OCPPError,
493 messageType: MessageType,
494 commandName: RequestCommand | IncomingRequestCommand,
495 ): string {
496 let messageToSend: string;
497 // Type of message
498 switch (messageType) {
499 // Request
500 case MessageType.CALL_MESSAGE:
501 // Build request
502 this.validateRequestPayload(chargingStation, commandName, messagePayload as JsonType);
503 messageToSend = JSON.stringify([
504 messageType,
505 messageId,
506 commandName,
507 messagePayload,
508 ] as OutgoingRequest);
509 break;
510 // Response
511 case MessageType.CALL_RESULT_MESSAGE:
512 // Build response
513 this.validateIncomingRequestResponsePayload(
514 chargingStation,
515 commandName,
516 messagePayload as JsonType,
517 );
518 messageToSend = JSON.stringify([messageType, messageId, messagePayload] as Response);
519 break;
520 // Error Message
521 case MessageType.CALL_ERROR_MESSAGE:
522 // Build Error Message
523 messageToSend = JSON.stringify([
524 messageType,
525 messageId,
526 (messagePayload as OCPPError)?.code ?? ErrorType.GENERIC_ERROR,
527 (messagePayload as OCPPError)?.message ?? '',
528 (messagePayload as OCPPError)?.details ?? { commandName },
529 ] as ErrorResponse);
530 break;
531 }
532 return messageToSend;
533 }
534
535 private cacheRequestPromise(
536 chargingStation: ChargingStation,
537 messageId: string,
538 messagePayload: JsonType,
539 commandName: RequestCommand | IncomingRequestCommand,
540 responseCallback: ResponseCallback,
541 errorCallback: ErrorCallback,
542 ): void {
543 chargingStation.requests.set(messageId, [
544 responseCallback,
545 errorCallback,
546 commandName,
547 messagePayload,
548 ]);
549 }
550
551 // eslint-disable-next-line @typescript-eslint/no-unused-vars
552 public abstract requestHandler<ReqType extends JsonType, ResType extends JsonType>(
553 chargingStation: ChargingStation,
554 commandName: RequestCommand,
555 // FIXME: should be ReqType
556 commandParams?: JsonType,
557 params?: RequestParams,
558 ): Promise<ResType>;
559 }