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