aeb09c332b7629acd8340fb874fd8d90348ecef4
[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.js';
5 import type { OCPPResponseService } from './OCPPResponseService.js';
6 import { OCPPServiceUtils } from './OCPPServiceUtils.js';
7 import type { ChargingStation } from '../../charging-station/index.js';
8 import { OCPPError } from '../../exception/index.js';
9 import { PerformanceStatistics } from '../../performance/index.js';
10 import {
11 ChargingStationEvents,
12 type ErrorCallback,
13 type ErrorResponse,
14 ErrorType,
15 type IncomingRequestCommand,
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/index.js';
26 import {
27 cloneObject,
28 formatDurationMilliSeconds,
29 handleSendMessageError,
30 isNullOrUndefined,
31 logger,
32 } from '../../utils/index.js';
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(reject)
348 .finally(() => {
349 chargingStation.requests.delete(messageId);
350 chargingStation.emit(ChargingStationEvents.updated);
351 });
352 };
353
354 /**
355 * Function that will receive the request's error response
356 *
357 * @param ocppError -
358 * @param requestStatistic -
359 */
360 const errorCallback = (ocppError: OCPPError, requestStatistic = true): void => {
361 if (requestStatistic === true && chargingStation.stationInfo?.enableStatistics === true) {
362 chargingStation.performanceStatistics?.addRequestStatistic(
363 commandName,
364 MessageType.CALL_ERROR_MESSAGE,
365 );
366 }
367 logger.error(
368 `${chargingStation.logPrefix()} Error occurred at ${OCPPServiceUtils.getMessageTypeString(
369 messageType,
370 )} command ${commandName} with PDU %j:`,
371 messagePayload,
372 ocppError,
373 );
374 chargingStation.requests.delete(messageId);
375 chargingStation.emit(ChargingStationEvents.updated);
376 reject(ocppError);
377 };
378
379 const handleSendError = (ocppError: OCPPError): void => {
380 if (params?.skipBufferingOnError === false) {
381 // Buffer
382 chargingStation.bufferMessage(messageToSend);
383 if (messageType === MessageType.CALL_MESSAGE) {
384 this.cacheRequestPromise(
385 chargingStation,
386 messageId,
387 messagePayload as JsonType,
388 commandName,
389 responseCallback,
390 errorCallback,
391 );
392 }
393 } else if (
394 params?.skipBufferingOnError === true &&
395 messageType === MessageType.CALL_MESSAGE
396 ) {
397 // Remove request from the cache
398 chargingStation.requests.delete(messageId);
399 }
400 return reject(ocppError);
401 };
402
403 if (chargingStation.stationInfo?.enableStatistics === true) {
404 chargingStation.performanceStatistics?.addRequestStatistic(commandName, messageType);
405 }
406 const messageToSend = this.buildMessageToSend(
407 chargingStation,
408 messageId,
409 messagePayload,
410 messageType,
411 commandName,
412 );
413 // Check if wsConnection opened
414 if (chargingStation.isWebSocketConnectionOpened() === true) {
415 const beginId = PerformanceStatistics.beginMeasure(commandName);
416 const sendTimeout = setTimeout(() => {
417 return handleSendError(
418 new OCPPError(
419 ErrorType.GENERIC_ERROR,
420 `Timeout ${formatDurationMilliSeconds(
421 OCPPConstants.OCPP_WEBSOCKET_TIMEOUT,
422 )} reached for ${
423 params?.skipBufferingOnError === false ? '' : 'non '
424 }buffered message id '${messageId}' with content '${messageToSend}'`,
425 commandName,
426 (messagePayload as OCPPError).details,
427 ),
428 );
429 }, OCPPConstants.OCPP_WEBSOCKET_TIMEOUT);
430 chargingStation.wsConnection?.send(messageToSend, (error?: Error) => {
431 PerformanceStatistics.endMeasure(commandName, beginId);
432 clearTimeout(sendTimeout);
433 if (isNullOrUndefined(error)) {
434 logger.debug(
435 `${chargingStation.logPrefix()} >> Command '${commandName}' sent ${OCPPServiceUtils.getMessageTypeString(
436 messageType,
437 )} payload: ${messageToSend}`,
438 );
439 if (messageType === MessageType.CALL_MESSAGE) {
440 this.cacheRequestPromise(
441 chargingStation,
442 messageId,
443 messagePayload as JsonType,
444 commandName,
445 responseCallback,
446 errorCallback,
447 );
448 } else {
449 // Resolve response
450 return resolve(messagePayload);
451 }
452 } else if (error) {
453 return handleSendError(
454 new OCPPError(
455 ErrorType.GENERIC_ERROR,
456 `WebSocket errored for ${
457 params?.skipBufferingOnError === false ? '' : 'non '
458 }buffered message id '${messageId}' with content '${messageToSend}'`,
459 commandName,
460 { name: error.name, message: error.message, stack: error.stack },
461 ),
462 );
463 }
464 });
465 } else {
466 return handleSendError(
467 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 OCPPError).details,
474 ),
475 );
476 }
477 });
478 }
479 throw new OCPPError(
480 ErrorType.SECURITY_ERROR,
481 `Cannot send command ${commandName} PDU when the charging station is in ${chargingStation?.bootNotificationResponse?.status} state on the central server`,
482 commandName,
483 );
484 }
485
486 private buildMessageToSend(
487 chargingStation: ChargingStation,
488 messageId: string,
489 messagePayload: JsonType | OCPPError,
490 messageType: MessageType,
491 commandName: RequestCommand | IncomingRequestCommand,
492 ): string {
493 let messageToSend: string;
494 // Type of message
495 switch (messageType) {
496 // Request
497 case MessageType.CALL_MESSAGE:
498 // Build request
499 this.validateRequestPayload(chargingStation, commandName, messagePayload as JsonType);
500 messageToSend = JSON.stringify([
501 messageType,
502 messageId,
503 commandName,
504 messagePayload,
505 ] as OutgoingRequest);
506 break;
507 // Response
508 case MessageType.CALL_RESULT_MESSAGE:
509 // Build response
510 this.validateIncomingRequestResponsePayload(
511 chargingStation,
512 commandName,
513 messagePayload as JsonType,
514 );
515 messageToSend = JSON.stringify([messageType, messageId, messagePayload] as Response);
516 break;
517 // Error Message
518 case MessageType.CALL_ERROR_MESSAGE:
519 // Build Error Message
520 messageToSend = JSON.stringify([
521 messageType,
522 messageId,
523 (messagePayload as OCPPError).code,
524 (messagePayload as OCPPError).message,
525 (messagePayload as OCPPError).details ?? {
526 command: (messagePayload as OCPPError).command ?? commandName,
527 },
528 ] as ErrorResponse);
529 break;
530 }
531 return messageToSend;
532 }
533
534 private cacheRequestPromise(
535 chargingStation: ChargingStation,
536 messageId: string,
537 messagePayload: JsonType,
538 commandName: RequestCommand | IncomingRequestCommand,
539 responseCallback: ResponseCallback,
540 errorCallback: ErrorCallback,
541 ): void {
542 chargingStation.requests.set(messageId, [
543 responseCallback,
544 errorCallback,
545 commandName,
546 messagePayload,
547 ]);
548 }
549
550 // eslint-disable-next-line @typescript-eslint/no-unused-vars
551 public abstract requestHandler<ReqType extends JsonType, ResType extends JsonType>(
552 chargingStation: ChargingStation,
553 commandName: RequestCommand,
554 // FIXME: should be ReqType
555 commandParams?: JsonType,
556 params?: RequestParams,
557 ): Promise<ResType>;
558 }