77e87f43e31be8550c5e568cbdbc71c5e63c8a54
[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(reject)
348 .finally(() => {
349 chargingStation.requests.delete(messageId);
350 });
351 };
352
353 /**
354 * Function that will receive the request's error response
355 *
356 * @param ocppError -
357 * @param requestStatistic -
358 */
359 const errorCallback = (ocppError: OCPPError, requestStatistic = true): void => {
360 if (requestStatistic === true && chargingStation.stationInfo?.enableStatistics === true) {
361 chargingStation.performanceStatistics?.addRequestStatistic(
362 commandName,
363 MessageType.CALL_ERROR_MESSAGE,
364 );
365 }
366 logger.error(
367 `${chargingStation.logPrefix()} Error occurred at ${OCPPServiceUtils.getMessageTypeString(
368 messageType,
369 )} command ${commandName} with PDU %j:`,
370 messagePayload,
371 ocppError,
372 );
373 chargingStation.requests.delete(messageId);
374 reject(ocppError);
375 };
376
377 const rejectAndCleanRequestsCache = (ocppError: OCPPError): void => {
378 // Remove request from the cache
379 if (messageType === MessageType.CALL_MESSAGE) {
380 chargingStation.requests.delete(messageId);
381 }
382 return reject(ocppError);
383 };
384
385 const handleSendError = (ocppError: OCPPError): void => {
386 if (params?.skipBufferingOnError === false) {
387 // Buffer
388 chargingStation.bufferMessage(messageToSend);
389 if (messageType === MessageType.CALL_MESSAGE) {
390 this.cacheRequestPromise(
391 chargingStation,
392 messageId,
393 messagePayload as JsonType,
394 commandName,
395 responseCallback,
396 errorCallback,
397 );
398 }
399 // Reject and keep request in the cache
400 return reject(ocppError);
401 }
402 return rejectAndCleanRequestsCache(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 rejectAndCleanRequestsCache(
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 } else {
447 // Resolve response
448 return resolve(messagePayload);
449 }
450 } else if (error) {
451 return handleSendError(
452 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 );
461 }
462 });
463 } else {
464 return handleSendError(
465 new OCPPError(
466 ErrorType.GENERIC_ERROR,
467 `WebSocket closed for ${
468 params?.skipBufferingOnError === false ? '' : 'non '
469 }buffered message id '${messageId}' with content '${messageToSend}'`,
470 commandName,
471 (messagePayload as JsonObject)?.details ?? Constants.EMPTY_FROZEN_OBJECT,
472 ),
473 );
474 }
475 });
476 }
477 throw new OCPPError(
478 ErrorType.SECURITY_ERROR,
479 `Cannot send command ${commandName} PDU when the charging station is in ${chargingStation.getRegistrationStatus()} state on the central server`,
480 commandName,
481 );
482 }
483
484 private buildMessageToSend(
485 chargingStation: ChargingStation,
486 messageId: string,
487 messagePayload: JsonType | OCPPError,
488 messageType: MessageType,
489 commandName: RequestCommand | IncomingRequestCommand,
490 ): string {
491 let messageToSend: string;
492 // Type of message
493 switch (messageType) {
494 // Request
495 case MessageType.CALL_MESSAGE:
496 // Build request
497 this.validateRequestPayload(chargingStation, commandName, messagePayload as JsonType);
498 messageToSend = JSON.stringify([
499 messageType,
500 messageId,
501 commandName,
502 messagePayload,
503 ] as OutgoingRequest);
504 break;
505 // Response
506 case MessageType.CALL_RESULT_MESSAGE:
507 // Build response
508 this.validateIncomingRequestResponsePayload(
509 chargingStation,
510 commandName,
511 messagePayload as JsonType,
512 );
513 messageToSend = JSON.stringify([messageType, messageId, messagePayload] as Response);
514 break;
515 // Error Message
516 case MessageType.CALL_ERROR_MESSAGE:
517 // Build Error Message
518 messageToSend = JSON.stringify([
519 messageType,
520 messageId,
521 (messagePayload as OCPPError)?.code ?? ErrorType.GENERIC_ERROR,
522 (messagePayload as OCPPError)?.message ?? '',
523 (messagePayload as OCPPError)?.details ?? { commandName },
524 ] as ErrorResponse);
525 break;
526 }
527 return messageToSend;
528 }
529
530 private cacheRequestPromise(
531 chargingStation: ChargingStation,
532 messageId: string,
533 messagePayload: JsonType,
534 commandName: RequestCommand | IncomingRequestCommand,
535 responseCallback: ResponseCallback,
536 errorCallback: ErrorCallback,
537 ): void {
538 chargingStation.requests.set(messageId, [
539 responseCallback,
540 errorCallback,
541 commandName,
542 messagePayload,
543 ]);
544 }
545
546 // eslint-disable-next-line @typescript-eslint/no-unused-vars
547 public abstract requestHandler<ReqType extends JsonType, ResType extends JsonType>(
548 chargingStation: ChargingStation,
549 commandName: RequestCommand,
550 // FIXME: should be ReqType
551 commandParams?: JsonType,
552 params?: RequestParams,
553 ): Promise<ResType>;
554 }