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