1d698bd894fcaa6dd2d6194f77f0e036b8a19856
[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.js'
5 import type { OCPPResponseService } from './OCPPResponseService.js'
6 import { OCPPServiceUtils } from './OCPPServiceUtils.js'
7 import type { ChargingStation } from '../../charging-station/index.js'
8 import { OCPPError } from '../../exception/index.js'
9 import { PerformanceStatistics } from '../../performance/index.js'
10 import {
11 ChargingStationEvents,
12 type ErrorCallback,
13 type ErrorResponse,
14 ErrorType,
15 type IncomingRequestCommand,
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/index.js'
26 import {
27 cloneObject,
28 formatDurationMilliSeconds,
29 handleSendMessageError,
30 logger
31 } from '../../utils/index.js'
32 type Ajv = _Ajv.default
33 // eslint-disable-next-line @typescript-eslint/no-redeclare
34 const Ajv = _Ajv.default
35 const ajvFormats = _ajvFormats.default
36
37 const moduleName = 'OCPPRequestService'
38
39 const defaultRequestParams: RequestParams = {
40 skipBufferingOnError: false,
41 triggerMessage: false,
42 throwError: false
43 }
44
45 export abstract class OCPPRequestService {
46 private static instance: OCPPRequestService | null = null
47 private readonly version: OCPPVersion
48 private readonly ajv: Ajv
49 private readonly ocppResponseService: OCPPResponseService
50 private readonly jsonValidateFunctions: Map<RequestCommand, ValidateFunction<JsonType>>
51 protected abstract jsonSchemas: Map<RequestCommand, JSONSchemaType<JsonType>>
52
53 protected constructor (version: OCPPVersion, ocppResponseService: OCPPResponseService) {
54 this.version = version
55 this.ajv = new Ajv({
56 keywords: ['javaType'],
57 multipleOfPrecision: 2
58 })
59 ajvFormats(this.ajv)
60 this.jsonValidateFunctions = new Map<RequestCommand, ValidateFunction<JsonType>>()
61 this.ocppResponseService = ocppResponseService
62 this.requestHandler = this.requestHandler.bind(this) as <
63 // eslint-disable-next-line @typescript-eslint/no-unused-vars
64 ReqType extends JsonType,
65 ResType extends JsonType,
66 >(
67 chargingStation: ChargingStation,
68 commandName: RequestCommand,
69 commandParams?: JsonType,
70 params?: RequestParams
71 ) => Promise<ResType>
72 this.sendMessage = this.sendMessage.bind(this) as (
73 chargingStation: ChargingStation,
74 messageId: string,
75 messagePayload: JsonType,
76 commandName: RequestCommand,
77 params?: RequestParams
78 ) => Promise<ResponseType>
79 this.sendResponse = this.sendResponse.bind(this) as (
80 chargingStation: ChargingStation,
81 messageId: string,
82 messagePayload: JsonType,
83 commandName: IncomingRequestCommand
84 ) => Promise<ResponseType>
85 this.sendError = this.sendError.bind(this) as (
86 chargingStation: ChargingStation,
87 messageId: string,
88 ocppError: OCPPError,
89 commandName: RequestCommand | IncomingRequestCommand
90 ) => Promise<ResponseType>
91 this.internalSendMessage = this.internalSendMessage.bind(this) as (
92 chargingStation: ChargingStation,
93 messageId: string,
94 messagePayload: JsonType | OCPPError,
95 messageType: MessageType,
96 commandName: RequestCommand | IncomingRequestCommand,
97 params?: RequestParams
98 ) => Promise<ResponseType>
99 this.buildMessageToSend = this.buildMessageToSend.bind(this) as (
100 chargingStation: ChargingStation,
101 messageId: string,
102 messagePayload: JsonType | OCPPError,
103 messageType: MessageType,
104 commandName: RequestCommand | IncomingRequestCommand
105 ) => string
106 this.validateRequestPayload = this.validateRequestPayload.bind(this) as <T extends JsonType>(
107 chargingStation: ChargingStation,
108 commandName: RequestCommand | IncomingRequestCommand,
109 payload: T
110 ) => boolean
111 this.validateIncomingRequestResponsePayload = this.validateIncomingRequestResponsePayload.bind(
112 this
113 ) as <T extends JsonType>(
114 chargingStation: ChargingStation,
115 commandName: RequestCommand | IncomingRequestCommand,
116 payload: T
117 ) => boolean
118 }
119
120 public static getInstance<T extends OCPPRequestService>(
121 this: new (ocppResponseService: OCPPResponseService) => T,
122 ocppResponseService: OCPPResponseService
123 ): T {
124 if (OCPPRequestService.instance === null) {
125 OCPPRequestService.instance = new this(ocppResponseService)
126 }
127 return OCPPRequestService.instance as T
128 }
129
130 public async sendResponse (
131 chargingStation: ChargingStation,
132 messageId: string,
133 messagePayload: JsonType,
134 commandName: IncomingRequestCommand
135 ): Promise<ResponseType> {
136 try {
137 // Send response message
138 return await this.internalSendMessage(
139 chargingStation,
140 messageId,
141 messagePayload,
142 MessageType.CALL_RESULT_MESSAGE,
143 commandName
144 )
145 } catch (error) {
146 handleSendMessageError(chargingStation, commandName, error as Error, {
147 throwError: true
148 })
149 return null
150 }
151 }
152
153 public async sendError (
154 chargingStation: ChargingStation,
155 messageId: string,
156 ocppError: OCPPError,
157 commandName: RequestCommand | IncomingRequestCommand
158 ): Promise<ResponseType> {
159 try {
160 // Send error message
161 return await this.internalSendMessage(
162 chargingStation,
163 messageId,
164 ocppError,
165 MessageType.CALL_ERROR_MESSAGE,
166 commandName
167 )
168 } catch (error) {
169 handleSendMessageError(chargingStation, commandName, error as Error)
170 return null
171 }
172 }
173
174 protected async sendMessage (
175 chargingStation: ChargingStation,
176 messageId: string,
177 messagePayload: JsonType,
178 commandName: RequestCommand,
179 params?: RequestParams
180 ): Promise<ResponseType> {
181 params = {
182 ...defaultRequestParams,
183 ...params
184 }
185 try {
186 return await this.internalSendMessage(
187 chargingStation,
188 messageId,
189 messagePayload,
190 MessageType.CALL_MESSAGE,
191 commandName,
192 params
193 )
194 } catch (error) {
195 handleSendMessageError(chargingStation, commandName, error as Error, {
196 throwError: params.throwError
197 })
198 return null
199 }
200 }
201
202 private validateRequestPayload<T extends JsonType>(
203 chargingStation: ChargingStation,
204 commandName: RequestCommand | IncomingRequestCommand,
205 payload: T
206 ): boolean {
207 if (chargingStation.stationInfo?.ocppStrictCompliance === false) {
208 return true
209 }
210 if (!this.jsonSchemas.has(commandName as RequestCommand)) {
211 logger.warn(
212 `${chargingStation.logPrefix()} ${moduleName}.validateRequestPayload: No JSON schema found for command '${commandName}' PDU validation`
213 )
214 return true
215 }
216 const validate = this.getJsonRequestValidateFunction<T>(commandName as RequestCommand)
217 payload = cloneObject<T>(payload)
218 OCPPServiceUtils.convertDateToISOString<T>(payload)
219 if (validate(payload)) {
220 return true
221 }
222 logger.error(
223 `${chargingStation.logPrefix()} ${moduleName}.validateRequestPayload: Command '${commandName}' request PDU is invalid: %j`,
224 validate.errors
225 )
226 // OCPPError usage here is debatable: it's an error in the OCPP stack but not targeted to sendError().
227 throw new OCPPError(
228 OCPPServiceUtils.ajvErrorsToErrorType(validate.errors),
229 'Request PDU is invalid',
230 commandName,
231 JSON.stringify(validate.errors, undefined, 2)
232 )
233 }
234
235 private getJsonRequestValidateFunction<T extends JsonType>(
236 commandName: RequestCommand
237 ): ValidateFunction<JsonType> {
238 if (!this.jsonValidateFunctions.has(commandName)) {
239 this.jsonValidateFunctions.set(
240 commandName,
241 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
242 this.ajv.compile<T>(this.jsonSchemas.get(commandName)!).bind(this)
243 )
244 }
245 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
246 return this.jsonValidateFunctions.get(commandName)!
247 }
248
249 private validateIncomingRequestResponsePayload<T extends JsonType>(
250 chargingStation: ChargingStation,
251 commandName: RequestCommand | IncomingRequestCommand,
252 payload: T
253 ): boolean {
254 if (chargingStation.stationInfo?.ocppStrictCompliance === false) {
255 return true
256 }
257 if (
258 !this.ocppResponseService.jsonIncomingRequestResponseSchemas.has(
259 commandName as IncomingRequestCommand
260 )
261 ) {
262 logger.warn(
263 `${chargingStation.logPrefix()} ${moduleName}.validateIncomingRequestResponsePayload: No JSON schema found for command '${commandName}' PDU validation`
264 )
265 return true
266 }
267 const validate = this.getJsonRequestResponseValidateFunction<T>(
268 commandName as IncomingRequestCommand
269 )
270 payload = cloneObject<T>(payload)
271 OCPPServiceUtils.convertDateToISOString<T>(payload)
272 if (validate(payload)) {
273 return true
274 }
275 logger.error(
276 `${chargingStation.logPrefix()} ${moduleName}.validateIncomingRequestResponsePayload: Command '${commandName}' reponse PDU is invalid: %j`,
277 validate.errors
278 )
279 // OCPPError usage here is debatable: it's an error in the OCPP stack but not targeted to sendError().
280 throw new OCPPError(
281 OCPPServiceUtils.ajvErrorsToErrorType(validate.errors),
282 'Response PDU is invalid',
283 commandName,
284 JSON.stringify(validate.errors, undefined, 2)
285 )
286 }
287
288 private getJsonRequestResponseValidateFunction<T extends JsonType>(
289 commandName: IncomingRequestCommand
290 ): ValidateFunction<JsonType> {
291 if (!this.ocppResponseService.jsonIncomingRequestResponseValidateFunctions.has(commandName)) {
292 this.ocppResponseService.jsonIncomingRequestResponseValidateFunctions.set(
293 commandName,
294 this.ajv
295 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
296 .compile<T>(this.ocppResponseService.jsonIncomingRequestResponseSchemas.get(commandName)!)
297 .bind(this)
298 )
299 }
300 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
301 return this.ocppResponseService.jsonIncomingRequestResponseValidateFunctions.get(commandName)!
302 }
303
304 private async internalSendMessage (
305 chargingStation: ChargingStation,
306 messageId: string,
307 messagePayload: JsonType | OCPPError,
308 messageType: MessageType,
309 commandName: RequestCommand | IncomingRequestCommand,
310 params?: RequestParams
311 ): Promise<ResponseType> {
312 params = {
313 ...defaultRequestParams,
314 ...params
315 }
316 if (
317 (chargingStation.inUnknownState() && commandName === RequestCommand.BOOT_NOTIFICATION) ||
318 (chargingStation.stationInfo?.ocppStrictCompliance === false &&
319 chargingStation.inUnknownState()) ||
320 chargingStation.inAcceptedState() ||
321 (chargingStation.inPendingState() &&
322 (params.triggerMessage === true || messageType === MessageType.CALL_RESULT_MESSAGE))
323 ) {
324 // eslint-disable-next-line @typescript-eslint/no-this-alias
325 const self = this
326 // Send a message through wsConnection
327 return await new Promise<ResponseType>((resolve, reject) => {
328 /**
329 * Function that will receive the request's response
330 *
331 * @param payload -
332 * @param requestPayload -
333 */
334 const responseCallback = (payload: JsonType, requestPayload: JsonType): void => {
335 if (chargingStation.stationInfo?.enableStatistics === true) {
336 chargingStation.performanceStatistics?.addRequestStatistic(
337 commandName,
338 MessageType.CALL_RESULT_MESSAGE
339 )
340 }
341 // Handle the request's response
342 self.ocppResponseService
343 .responseHandler(
344 chargingStation,
345 commandName as RequestCommand,
346 payload,
347 requestPayload
348 )
349 .then(() => {
350 resolve(payload)
351 })
352 .catch(reject)
353 .finally(() => {
354 chargingStation.requests.delete(messageId)
355 chargingStation.emit(ChargingStationEvents.updated)
356 })
357 }
358
359 /**
360 * Function that will receive the request's error response
361 *
362 * @param ocppError -
363 * @param requestStatistic -
364 */
365 const errorCallback = (ocppError: OCPPError, requestStatistic = true): void => {
366 if (requestStatistic && chargingStation.stationInfo?.enableStatistics === true) {
367 chargingStation.performanceStatistics?.addRequestStatistic(
368 commandName,
369 MessageType.CALL_ERROR_MESSAGE
370 )
371 }
372 logger.error(
373 `${chargingStation.logPrefix()} Error occurred at ${OCPPServiceUtils.getMessageTypeString(
374 messageType
375 )} command ${commandName} with PDU %j:`,
376 messagePayload,
377 ocppError
378 )
379 chargingStation.requests.delete(messageId)
380 chargingStation.emit(ChargingStationEvents.updated)
381 reject(ocppError)
382 }
383
384 const handleSendError = (ocppError: OCPPError): void => {
385 if (params?.skipBufferingOnError === false) {
386 // Buffer
387 chargingStation.bufferMessage(messageToSend)
388 if (messageType === MessageType.CALL_MESSAGE) {
389 this.cacheRequestPromise(
390 chargingStation,
391 messageId,
392 messagePayload as JsonType,
393 commandName,
394 responseCallback,
395 errorCallback
396 )
397 }
398 } else if (
399 params?.skipBufferingOnError === true &&
400 messageType === MessageType.CALL_MESSAGE
401 ) {
402 // Remove request from the cache
403 chargingStation.requests.delete(messageId)
404 }
405 reject(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()) {
420 const beginId = PerformanceStatistics.beginMeasure(commandName)
421 const sendTimeout = setTimeout(() => {
422 handleSendError(
423 new OCPPError(
424 ErrorType.GENERIC_ERROR,
425 `Timeout ${formatDurationMilliSeconds(
426 OCPPConstants.OCPP_WEBSOCKET_TIMEOUT
427 )} reached for ${
428 params?.skipBufferingOnError === false ? '' : 'non '
429 }buffered message id '${messageId}' with content '${messageToSend}'`,
430 commandName,
431 (messagePayload as OCPPError).details
432 )
433 )
434 }, OCPPConstants.OCPP_WEBSOCKET_TIMEOUT)
435 chargingStation.wsConnection?.send(messageToSend, (error?: Error) => {
436 PerformanceStatistics.endMeasure(commandName, beginId)
437 clearTimeout(sendTimeout)
438 if (error == null) {
439 logger.debug(
440 `${chargingStation.logPrefix()} >> Command '${commandName}' sent ${OCPPServiceUtils.getMessageTypeString(
441 messageType
442 )} payload: ${messageToSend}`
443 )
444 if (messageType === MessageType.CALL_MESSAGE) {
445 this.cacheRequestPromise(
446 chargingStation,
447 messageId,
448 messagePayload as JsonType,
449 commandName,
450 responseCallback,
451 errorCallback
452 )
453 } else {
454 // Resolve response
455 resolve(messagePayload)
456 }
457 } else {
458 handleSendError(
459 new OCPPError(
460 ErrorType.GENERIC_ERROR,
461 `WebSocket errored for ${
462 params?.skipBufferingOnError === false ? '' : 'non '
463 }buffered message id '${messageId}' with content '${messageToSend}'`,
464 commandName,
465 { name: error.name, message: error.message, stack: error.stack }
466 )
467 )
468 }
469 })
470 } else {
471 handleSendError(
472 new OCPPError(
473 ErrorType.GENERIC_ERROR,
474 `WebSocket closed for ${
475 params?.skipBufferingOnError === false ? '' : 'non '
476 }buffered message id '${messageId}' with content '${messageToSend}'`,
477 commandName,
478 (messagePayload as OCPPError).details
479 )
480 )
481 }
482 })
483 }
484 throw new OCPPError(
485 ErrorType.SECURITY_ERROR,
486 `Cannot send command ${commandName} PDU when the charging station is in ${chargingStation.bootNotificationResponse?.status} state on the central server`,
487 commandName
488 )
489 }
490
491 private buildMessageToSend (
492 chargingStation: ChargingStation,
493 messageId: string,
494 messagePayload: JsonType | OCPPError,
495 messageType: MessageType,
496 commandName: RequestCommand | IncomingRequestCommand
497 ): string {
498 let messageToSend: string
499 // Type of message
500 switch (messageType) {
501 // Request
502 case MessageType.CALL_MESSAGE:
503 // Build request
504 this.validateRequestPayload(chargingStation, commandName, messagePayload as JsonType)
505 messageToSend = JSON.stringify([
506 messageType,
507 messageId,
508 commandName,
509 messagePayload
510 ] as OutgoingRequest)
511 break
512 // Response
513 case MessageType.CALL_RESULT_MESSAGE:
514 // Build response
515 this.validateIncomingRequestResponsePayload(
516 chargingStation,
517 commandName,
518 messagePayload as JsonType
519 )
520 messageToSend = JSON.stringify([messageType, messageId, messagePayload] as Response)
521 break
522 // Error Message
523 case MessageType.CALL_ERROR_MESSAGE:
524 // Build Error Message
525 messageToSend = JSON.stringify([
526 messageType,
527 messageId,
528 (messagePayload as OCPPError).code,
529 (messagePayload as OCPPError).message,
530 (messagePayload as OCPPError).details ?? {
531 command: (messagePayload as OCPPError).command ?? commandName
532 }
533 ] as ErrorResponse)
534 break
535 }
536 return messageToSend
537 }
538
539 private cacheRequestPromise (
540 chargingStation: ChargingStation,
541 messageId: string,
542 messagePayload: JsonType,
543 commandName: RequestCommand | IncomingRequestCommand,
544 responseCallback: ResponseCallback,
545 errorCallback: ErrorCallback
546 ): void {
547 chargingStation.requests.set(messageId, [
548 responseCallback,
549 errorCallback,
550 commandName,
551 messagePayload
552 ])
553 }
554
555 // eslint-disable-next-line @typescript-eslint/no-unused-vars
556 public abstract requestHandler<ReqType extends JsonType, ResType extends JsonType>(
557 chargingStation: ChargingStation,
558 commandName: RequestCommand,
559 // FIXME: should be ReqType
560 commandParams?: JsonType,
561 params?: RequestParams
562 ): Promise<ResType>
563 }