refactor: factor out WS error handling code
[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 handleSendError = (ocppError: OCPPError): void => {
389 if (params?.skipBufferingOnError === false) {
390 // Buffer
391 chargingStation.bufferMessage(messageToSend);
392 if (messageType === MessageType.CALL_MESSAGE) {
393 this.cacheRequestPromise(
394 chargingStation,
395 messageId,
396 messagePayload as JsonType,
397 commandName,
398 responseCallback,
399 errorCallback,
400 );
401 }
402 // Reject and keep request in the cache
403 return reject(ocppError);
404 }
405 return rejectWithOcppError(ocppError);
406 };
407
408 if (chargingStation.stationInfo?.enableStatistics === true) {
409 chargingStation.performanceStatistics?.addRequestStatistic(commandName, messageType);
410 }
411 const messageToSend = this.buildMessageToSend(
412 chargingStation,
413 messageId,
414 messagePayload,
415 messageType,
416 commandName,
417 );
418 // Check if wsConnection opened
419 if (chargingStation.isWebSocketConnectionOpened() === true) {
420 const beginId = PerformanceStatistics.beginMeasure(commandName);
421 const sendTimeout = setTimeout(() => {
422 return rejectWithOcppError(
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 }, OCPPConstants.OCPP_WEBSOCKET_TIMEOUT);
431 chargingStation.wsConnection?.send(messageToSend, (error?: Error) => {
432 PerformanceStatistics.endMeasure(commandName, beginId);
433 clearTimeout(sendTimeout);
434 if (isNullOrUndefined(error)) {
435 logger.debug(
436 `${chargingStation.logPrefix()} >> Command '${commandName}' sent ${OCPPServiceUtils.getMessageTypeString(
437 messageType,
438 )} payload: ${messageToSend}`,
439 );
440 if (messageType === MessageType.CALL_MESSAGE) {
441 this.cacheRequestPromise(
442 chargingStation,
443 messageId,
444 messagePayload as JsonType,
445 commandName,
446 responseCallback,
447 errorCallback,
448 );
449 } else {
450 // Resolve response
451 return resolve(messagePayload);
452 }
453 } else if (error) {
454 return handleSendError(
455 new OCPPError(
456 ErrorType.GENERIC_ERROR,
457 `WebSocket errored for ${
458 params?.skipBufferingOnError === false ? '' : 'non '
459 }buffered message id '${messageId}' with content '${messageToSend}'`,
460 commandName,
461 { name: error.name, message: error.message, stack: error.stack },
462 ),
463 );
464 }
465 });
466 } else {
467 return handleSendError(
468 new OCPPError(
469 ErrorType.GENERIC_ERROR,
470 `WebSocket closed for ${
471 params?.skipBufferingOnError === false ? '' : 'non '
472 }buffered message id '${messageId}' with content '${messageToSend}'`,
473 commandName,
474 (messagePayload as JsonObject)?.details ?? Constants.EMPTY_FROZEN_OBJECT,
475 ),
476 );
477 }
478 });
479 }
480 throw new OCPPError(
481 ErrorType.SECURITY_ERROR,
482 `Cannot send command ${commandName} PDU when the charging station is in ${chargingStation.getRegistrationStatus()} state on the central server`,
483 commandName,
484 );
485 }
486
487 private buildMessageToSend(
488 chargingStation: ChargingStation,
489 messageId: string,
490 messagePayload: JsonType | OCPPError,
491 messageType: MessageType,
492 commandName: RequestCommand | IncomingRequestCommand,
493 ): string {
494 let messageToSend: string;
495 // Type of message
496 switch (messageType) {
497 // Request
498 case MessageType.CALL_MESSAGE:
499 // Build request
500 this.validateRequestPayload(chargingStation, commandName, messagePayload as JsonType);
501 messageToSend = JSON.stringify([
502 messageType,
503 messageId,
504 commandName,
505 messagePayload,
506 ] as OutgoingRequest);
507 break;
508 // Response
509 case MessageType.CALL_RESULT_MESSAGE:
510 // Build response
511 this.validateIncomingRequestResponsePayload(
512 chargingStation,
513 commandName,
514 messagePayload as JsonType,
515 );
516 messageToSend = JSON.stringify([messageType, messageId, messagePayload] as Response);
517 break;
518 // Error Message
519 case MessageType.CALL_ERROR_MESSAGE:
520 // Build Error Message
521 messageToSend = JSON.stringify([
522 messageType,
523 messageId,
524 (messagePayload as OCPPError)?.code ?? ErrorType.GENERIC_ERROR,
525 (messagePayload as OCPPError)?.message ?? '',
526 (messagePayload as OCPPError)?.details ?? { commandName },
527 ] as ErrorResponse);
528 break;
529 }
530 return messageToSend;
531 }
532
533 private cacheRequestPromise(
534 chargingStation: ChargingStation,
535 messageId: string,
536 messagePayload: JsonType,
537 commandName: RequestCommand | IncomingRequestCommand,
538 responseCallback: ResponseCallback,
539 errorCallback: ErrorCallback,
540 ): void {
541 chargingStation.requests.set(messageId, [
542 responseCallback,
543 errorCallback,
544 commandName,
545 messagePayload,
546 ]);
547 }
548
549 // eslint-disable-next-line @typescript-eslint/no-unused-vars
550 public abstract requestHandler<ReqType extends JsonType, ResType extends JsonType>(
551 chargingStation: ChargingStation,
552 commandName: RequestCommand,
553 // FIXME: should be ReqType
554 commandParams?: JsonType,
555 params?: RequestParams,
556 ): Promise<ResType>;
557 }