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