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