refactor(simulator): remove duplicated code in utils
[e-mobility-charging-stations-simulator.git] / src / charging-station / ocpp / OCPPRequestService.ts
1 import Ajv, { type JSONSchemaType } from 'ajv';
2 import ajvFormats from 'ajv-formats';
3
4 import { type OCPPResponseService, OCPPServiceUtils } from './internal';
5 import type { ChargingStation } from '../../charging-station';
6 import { OCPPError } from '../../exception';
7 import { PerformanceStatistics } from '../../performance';
8 import {
9 type EmptyObject,
10 type ErrorCallback,
11 type ErrorResponse,
12 ErrorType,
13 type HandleErrorParams,
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, Utils, logger } from '../../utils';
27
28 const moduleName = 'OCPPRequestService';
29
30 export abstract class OCPPRequestService {
31 private static instance: OCPPRequestService | null = null;
32 private readonly version: OCPPVersion;
33 private readonly ajv: Ajv;
34 private readonly ocppResponseService: OCPPResponseService;
35 protected abstract jsonSchemas: Map<RequestCommand, JSONSchemaType<JsonObject>>;
36
37 protected constructor(version: OCPPVersion, ocppResponseService: OCPPResponseService) {
38 this.version = version;
39 this.ajv = new Ajv({
40 keywords: ['javaType'],
41 multipleOfPrecision: 2,
42 });
43 ajvFormats(this.ajv);
44 this.ocppResponseService = ocppResponseService;
45 this.requestHandler.bind(this);
46 this.sendMessage.bind(this);
47 this.sendResponse.bind(this);
48 this.sendError.bind(this);
49 this.internalSendMessage.bind(this);
50 this.buildMessageToSend.bind(this);
51 this.validateRequestPayload.bind(this);
52 this.validateIncomingRequestResponsePayload.bind(this);
53 }
54
55 public static getInstance<T extends OCPPRequestService>(
56 this: new (ocppResponseService: OCPPResponseService) => T,
57 ocppResponseService: OCPPResponseService
58 ): T {
59 if (OCPPRequestService.instance === null) {
60 OCPPRequestService.instance = new this(ocppResponseService);
61 }
62 return OCPPRequestService.instance as T;
63 }
64
65 public async sendResponse(
66 chargingStation: ChargingStation,
67 messageId: string,
68 messagePayload: JsonType,
69 commandName: IncomingRequestCommand
70 ): Promise<ResponseType> {
71 try {
72 // Send response message
73 return await this.internalSendMessage(
74 chargingStation,
75 messageId,
76 messagePayload,
77 MessageType.CALL_RESULT_MESSAGE,
78 commandName
79 );
80 } catch (error) {
81 this.handleSendMessageError(chargingStation, commandName, error as Error, {
82 throwError: true,
83 });
84 }
85 }
86
87 public async sendError(
88 chargingStation: ChargingStation,
89 messageId: string,
90 ocppError: OCPPError,
91 commandName: RequestCommand | IncomingRequestCommand
92 ): Promise<ResponseType> {
93 try {
94 // Send error message
95 return await this.internalSendMessage(
96 chargingStation,
97 messageId,
98 ocppError,
99 MessageType.CALL_ERROR_MESSAGE,
100 commandName
101 );
102 } catch (error) {
103 this.handleSendMessageError(chargingStation, commandName, error as Error);
104 }
105 }
106
107 protected async sendMessage(
108 chargingStation: ChargingStation,
109 messageId: string,
110 messagePayload: JsonType,
111 commandName: RequestCommand,
112 params: RequestParams = {
113 skipBufferingOnError: false,
114 triggerMessage: false,
115 throwError: false,
116 }
117 ): Promise<ResponseType> {
118 try {
119 return await this.internalSendMessage(
120 chargingStation,
121 messageId,
122 messagePayload,
123 MessageType.CALL_MESSAGE,
124 commandName,
125 params
126 );
127 } catch (error) {
128 this.handleSendMessageError(chargingStation, commandName, error as Error, {
129 throwError: params.throwError,
130 });
131 }
132 }
133
134 private validateRequestPayload<T extends JsonObject>(
135 chargingStation: ChargingStation,
136 commandName: RequestCommand | IncomingRequestCommand,
137 payload: T
138 ): boolean {
139 if (chargingStation.getPayloadSchemaValidation() === false) {
140 return true;
141 }
142 if (this.jsonSchemas.has(commandName as RequestCommand) === false) {
143 logger.warn(
144 `${chargingStation.logPrefix()} ${moduleName}.validateRequestPayload: No JSON schema found for command '${commandName}' PDU validation`
145 );
146 return true;
147 }
148 const validate = this.ajv.compile(this.jsonSchemas.get(commandName as RequestCommand));
149 payload = Utils.cloneObject<T>(payload);
150 OCPPServiceUtils.convertDateToISOString<T>(payload);
151 if (validate(payload)) {
152 return true;
153 }
154 logger.error(
155 `${chargingStation.logPrefix()} ${moduleName}.validateRequestPayload: Command '${commandName}' request PDU is invalid: %j`,
156 validate.errors
157 );
158 // OCPPError usage here is debatable: it's an error in the OCPP stack but not targeted to sendError().
159 throw new OCPPError(
160 OCPPServiceUtils.ajvErrorsToErrorType(validate.errors),
161 'Request PDU is invalid',
162 commandName,
163 JSON.stringify(validate.errors, null, 2)
164 );
165 }
166
167 private validateIncomingRequestResponsePayload<T extends JsonObject>(
168 chargingStation: ChargingStation,
169 commandName: RequestCommand | IncomingRequestCommand,
170 payload: T
171 ): boolean {
172 if (chargingStation.getPayloadSchemaValidation() === false) {
173 return true;
174 }
175 if (
176 this.ocppResponseService.jsonIncomingRequestResponseSchemas.has(
177 commandName as IncomingRequestCommand
178 ) === false
179 ) {
180 logger.warn(
181 `${chargingStation.logPrefix()} ${moduleName}.validateIncomingRequestResponsePayload: No JSON schema found for command '${commandName}' PDU validation`
182 );
183 return true;
184 }
185 const validate = this.ajv.compile(
186 this.ocppResponseService.jsonIncomingRequestResponseSchemas.get(
187 commandName as IncomingRequestCommand
188 )
189 );
190 payload = Utils.cloneObject<T>(payload);
191 OCPPServiceUtils.convertDateToISOString<T>(payload);
192 if (validate(payload)) {
193 return true;
194 }
195 logger.error(
196 `${chargingStation.logPrefix()} ${moduleName}.validateIncomingRequestResponsePayload: Command '${commandName}' reponse PDU is invalid: %j`,
197 validate.errors
198 );
199 // OCPPError usage here is debatable: it's an error in the OCPP stack but not targeted to sendError().
200 throw new OCPPError(
201 OCPPServiceUtils.ajvErrorsToErrorType(validate.errors),
202 'Response PDU is invalid',
203 commandName,
204 JSON.stringify(validate.errors, null, 2)
205 );
206 }
207
208 private async internalSendMessage(
209 chargingStation: ChargingStation,
210 messageId: string,
211 messagePayload: JsonType | OCPPError,
212 messageType: MessageType,
213 commandName: RequestCommand | IncomingRequestCommand,
214 params: RequestParams = {
215 skipBufferingOnError: false,
216 triggerMessage: false,
217 }
218 ): Promise<ResponseType> {
219 if (
220 (chargingStation.isInUnknownState() === true &&
221 commandName === RequestCommand.BOOT_NOTIFICATION) ||
222 (chargingStation.getOcppStrictCompliance() === false &&
223 chargingStation.isInUnknownState() === true) ||
224 chargingStation.isInAcceptedState() === true ||
225 (chargingStation.isInPendingState() === true &&
226 (params.triggerMessage === true || messageType === MessageType.CALL_RESULT_MESSAGE))
227 ) {
228 // eslint-disable-next-line @typescript-eslint/no-this-alias
229 const self = this;
230 // Send a message through wsConnection
231 return Utils.promiseWithTimeout(
232 new Promise((resolve, reject) => {
233 if (chargingStation.getEnableStatistics() === true) {
234 chargingStation.performanceStatistics?.addRequestStatistic(commandName, messageType);
235 }
236 const messageToSend = this.buildMessageToSend(
237 chargingStation,
238 messageId,
239 messagePayload,
240 messageType,
241 commandName,
242 responseCallback,
243 errorCallback
244 );
245 let sendError = false;
246 // Check if wsConnection opened
247 const wsOpened = chargingStation.isWebSocketConnectionOpened() === true;
248 if (wsOpened) {
249 const beginId = PerformanceStatistics.beginMeasure(commandName);
250 try {
251 chargingStation.wsConnection?.send(messageToSend);
252 logger.debug(
253 `${chargingStation.logPrefix()} >> Command '${commandName}' sent ${OCPPServiceUtils.getMessageTypeString(
254 messageType
255 )} payload: ${messageToSend}`
256 );
257 } catch (error) {
258 logger.error(
259 `${chargingStation.logPrefix()} >> Command '${commandName}' failed to send ${OCPPServiceUtils.getMessageTypeString(
260 messageType
261 )} payload: ${messageToSend}:`,
262 error
263 );
264 sendError = true;
265 }
266 PerformanceStatistics.endMeasure(commandName, beginId);
267 }
268 const wsClosedOrErrored = !wsOpened || sendError === true;
269 if (wsClosedOrErrored && params.skipBufferingOnError === false) {
270 // Buffer
271 chargingStation.bufferMessage(messageToSend);
272 // Reject and keep request in the cache
273 return reject(
274 new OCPPError(
275 ErrorType.GENERIC_ERROR,
276 `WebSocket closed or errored for buffered message id '${messageId}' with content '${messageToSend}'`,
277 commandName,
278 (messagePayload as JsonObject)?.details ?? {}
279 )
280 );
281 } else if (wsClosedOrErrored) {
282 const ocppError = new OCPPError(
283 ErrorType.GENERIC_ERROR,
284 `WebSocket closed or errored for non buffered message id '${messageId}' with content '${messageToSend}'`,
285 commandName,
286 (messagePayload as JsonObject)?.details ?? {}
287 );
288 // Reject response
289 if (messageType !== MessageType.CALL_MESSAGE) {
290 return reject(ocppError);
291 }
292 // Reject and remove request from the cache
293 return errorCallback(ocppError, false);
294 }
295 // Resolve response
296 if (messageType !== MessageType.CALL_MESSAGE) {
297 return resolve(messagePayload);
298 }
299
300 /**
301 * Function that will receive the request's response
302 *
303 * @param payload -
304 * @param requestPayload -
305 */
306 function responseCallback(payload: JsonType, requestPayload: JsonType): void {
307 if (chargingStation.getEnableStatistics() === true) {
308 chargingStation.performanceStatistics?.addRequestStatistic(
309 commandName,
310 MessageType.CALL_RESULT_MESSAGE
311 );
312 }
313 // Handle the request's response
314 self.ocppResponseService
315 .responseHandler(
316 chargingStation,
317 commandName as RequestCommand,
318 payload,
319 requestPayload
320 )
321 .then(() => {
322 resolve(payload);
323 })
324 .catch((error) => {
325 reject(error);
326 })
327 .finally(() => {
328 chargingStation.requests.delete(messageId);
329 });
330 }
331
332 /**
333 * Function that will receive the request's error response
334 *
335 * @param error -
336 * @param requestStatistic -
337 */
338 function errorCallback(error: OCPPError, requestStatistic = true): void {
339 if (requestStatistic === true && chargingStation.getEnableStatistics() === true) {
340 chargingStation.performanceStatistics?.addRequestStatistic(
341 commandName,
342 MessageType.CALL_ERROR_MESSAGE
343 );
344 }
345 logger.error(
346 `${chargingStation.logPrefix()} Error occurred at ${OCPPServiceUtils.getMessageTypeString(
347 messageType
348 )} command ${commandName} with PDU %j:`,
349 messagePayload,
350 error
351 );
352 chargingStation.requests.delete(messageId);
353 reject(error);
354 }
355 }),
356 Constants.OCPP_WEBSOCKET_TIMEOUT,
357 new OCPPError(
358 ErrorType.GENERIC_ERROR,
359 `Timeout for message id '${messageId}'`,
360 commandName,
361 (messagePayload as JsonObject)?.details ?? {}
362 ),
363 () => {
364 messageType === MessageType.CALL_MESSAGE && chargingStation.requests.delete(messageId);
365 }
366 );
367 }
368 throw new OCPPError(
369 ErrorType.SECURITY_ERROR,
370 `Cannot send command ${commandName} PDU when the charging station is in ${chargingStation.getRegistrationStatus()} state on the central server`,
371 commandName
372 );
373 }
374
375 private buildMessageToSend(
376 chargingStation: ChargingStation,
377 messageId: string,
378 messagePayload: JsonType | OCPPError,
379 messageType: MessageType,
380 commandName: RequestCommand | IncomingRequestCommand,
381 responseCallback: ResponseCallback,
382 errorCallback: ErrorCallback
383 ): string {
384 let messageToSend: string;
385 // Type of message
386 switch (messageType) {
387 // Request
388 case MessageType.CALL_MESSAGE:
389 // Build request
390 this.validateRequestPayload(chargingStation, commandName, messagePayload as JsonObject);
391 chargingStation.requests.set(messageId, [
392 responseCallback,
393 errorCallback,
394 commandName,
395 messagePayload as JsonType,
396 ]);
397 messageToSend = JSON.stringify([
398 messageType,
399 messageId,
400 commandName,
401 messagePayload,
402 ] as OutgoingRequest);
403 break;
404 // Response
405 case MessageType.CALL_RESULT_MESSAGE:
406 // Build response
407 this.validateIncomingRequestResponsePayload(
408 chargingStation,
409 commandName,
410 messagePayload as JsonObject
411 );
412 messageToSend = JSON.stringify([messageType, messageId, messagePayload] as Response);
413 break;
414 // Error Message
415 case MessageType.CALL_ERROR_MESSAGE:
416 // Build Error Message
417 messageToSend = JSON.stringify([
418 messageType,
419 messageId,
420 (messagePayload as OCPPError)?.code ?? ErrorType.GENERIC_ERROR,
421 (messagePayload as OCPPError)?.message ?? '',
422 (messagePayload as OCPPError)?.details ?? { commandName },
423 ] as ErrorResponse);
424 break;
425 }
426 return messageToSend;
427 }
428
429 private handleSendMessageError(
430 chargingStation: ChargingStation,
431 commandName: RequestCommand | IncomingRequestCommand,
432 error: Error,
433 params: HandleErrorParams<EmptyObject> = { throwError: false }
434 ): void {
435 logger.error(`${chargingStation.logPrefix()} Request command '${commandName}' error:`, error);
436 if (params?.throwError === true) {
437 throw error;
438 }
439 }
440
441 // eslint-disable-next-line @typescript-eslint/no-unused-vars
442 public abstract requestHandler<ReqType extends JsonType, ResType extends JsonType>(
443 chargingStation: ChargingStation,
444 commandName: RequestCommand,
445 commandParams?: JsonType,
446 params?: RequestParams
447 ): Promise<ResType>;
448 }