perf: cache only JSON payload validation functions
[e-mobility-charging-stations-simulator.git] / src / charging-station / ocpp / OCPPRequestService.ts
CommitLineData
24d15716 1import _Ajv, { type ValidateFunction } from 'ajv'
66a7748d 2import _ajvFormats from 'ajv-formats'
b52c969d 3
66a7748d
JB
4import { OCPPConstants } from './OCPPConstants.js'
5import type { OCPPResponseService } from './OCPPResponseService.js'
6import { OCPPServiceUtils } from './OCPPServiceUtils.js'
7import type { ChargingStation } from '../../charging-station/index.js'
8import { OCPPError } from '../../exception/index.js'
9import { PerformanceStatistics } from '../../performance/index.js'
e7aeea18 10import {
8baae8ee 11 ChargingStationEvents,
27782dbc 12 type ErrorCallback,
268a74bb
JB
13 type ErrorResponse,
14 ErrorType,
27782dbc 15 type IncomingRequestCommand,
268a74bb
JB
16 type JsonType,
17 MessageType,
18 type OCPPVersion,
27782dbc 19 type OutgoingRequest,
e7aeea18 20 RequestCommand,
27782dbc 21 type RequestParams,
268a74bb 22 type Response,
27782dbc 23 type ResponseCallback,
66a7748d
JB
24 type ResponseType
25} from '../../types/index.js'
2b94ad12 26import {
40615072 27 clone,
2b94ad12
JB
28 formatDurationMilliSeconds,
29 handleSendMessageError,
66a7748d
JB
30 logger
31} from '../../utils/index.js'
32type Ajv = _Ajv.default
33// eslint-disable-next-line @typescript-eslint/no-redeclare
34const Ajv = _Ajv.default
35const ajvFormats = _ajvFormats.default
c0560973 36
66a7748d 37const moduleName = 'OCPPRequestService'
e3018bc4 38
b9da1bc2
JB
39const defaultRequestParams: RequestParams = {
40 skipBufferingOnError: false,
41 triggerMessage: false,
66a7748d
JB
42 throwError: false
43}
b9da1bc2 44
268a74bb 45export abstract class OCPPRequestService {
66a7748d
JB
46 private static instance: OCPPRequestService | null = null
47 private readonly version: OCPPVersion
66a7748d 48 private readonly ocppResponseService: OCPPResponseService
24d15716
JB
49 protected readonly ajv: Ajv
50 protected abstract jsonSchemasValidateFunction: Map<RequestCommand, ValidateFunction<JsonType>>
c0560973 51
66a7748d
JB
52 protected constructor (version: OCPPVersion, ocppResponseService: OCPPResponseService) {
53 this.version = version
45988780 54 this.ajv = new Ajv({
98fc1389 55 keywords: ['javaType'],
66a7748d
JB
56 multipleOfPrecision: 2
57 })
58 ajvFormats(this.ajv)
66a7748d 59 this.ocppResponseService = ocppResponseService
ba9a56a6
JB
60 this.requestHandler = this.requestHandler.bind(this)
61 this.sendMessage = this.sendMessage.bind(this)
62 this.sendResponse = this.sendResponse.bind(this)
63 this.sendError = this.sendError.bind(this)
64 this.internalSendMessage = this.internalSendMessage.bind(this)
65 this.buildMessageToSend = this.buildMessageToSend.bind(this)
66 this.validateRequestPayload = this.validateRequestPayload.bind(this)
67 this.validateIncomingRequestResponsePayload =
68 this.validateIncomingRequestResponsePayload.bind(this)
c0560973
JB
69 }
70
e7aeea18 71 public static getInstance<T extends OCPPRequestService>(
08f130a0 72 this: new (ocppResponseService: OCPPResponseService) => T,
66a7748d 73 ocppResponseService: OCPPResponseService
e7aeea18 74 ): T {
1ca780f9 75 if (OCPPRequestService.instance === null) {
66a7748d 76 OCPPRequestService.instance = new this(ocppResponseService)
9f2e3130 77 }
66a7748d 78 return OCPPRequestService.instance as T
9f2e3130
JB
79 }
80
66a7748d 81 public async sendResponse (
08f130a0 82 chargingStation: ChargingStation,
e7aeea18 83 messageId: string,
5cc4b63b 84 messagePayload: JsonType,
66a7748d 85 commandName: IncomingRequestCommand
5eaabe90 86 ): Promise<ResponseType> {
5e0c67e8 87 try {
c75a6675 88 // Send response message
e7aeea18 89 return await this.internalSendMessage(
08f130a0 90 chargingStation,
e7aeea18
JB
91 messageId,
92 messagePayload,
93 MessageType.CALL_RESULT_MESSAGE,
66a7748d
JB
94 commandName
95 )
5e0c67e8 96 } catch (error) {
fa5995d6 97 handleSendMessageError(chargingStation, commandName, error as Error, {
66a7748d
JB
98 throwError: true
99 })
100 return null
5e0c67e8
JB
101 }
102 }
103
66a7748d 104 public async sendError (
08f130a0 105 chargingStation: ChargingStation,
e7aeea18
JB
106 messageId: string,
107 ocppError: OCPPError,
66a7748d 108 commandName: RequestCommand | IncomingRequestCommand
e7aeea18 109 ): Promise<ResponseType> {
5e0c67e8
JB
110 try {
111 // Send error message
e7aeea18 112 return await this.internalSendMessage(
08f130a0 113 chargingStation,
e7aeea18
JB
114 messageId,
115 ocppError,
116 MessageType.CALL_ERROR_MESSAGE,
66a7748d
JB
117 commandName
118 )
5e0c67e8 119 } catch (error) {
66a7748d
JB
120 handleSendMessageError(chargingStation, commandName, error as Error)
121 return null
5e0c67e8
JB
122 }
123 }
124
66a7748d 125 protected async sendMessage (
08f130a0 126 chargingStation: ChargingStation,
e7aeea18 127 messageId: string,
5cc4b63b 128 messagePayload: JsonType,
e7aeea18 129 commandName: RequestCommand,
66a7748d 130 params?: RequestParams
e7aeea18 131 ): Promise<ResponseType> {
7b5dbe91 132 params = {
b9da1bc2 133 ...defaultRequestParams,
66a7748d
JB
134 ...params
135 }
5e0c67e8 136 try {
e7aeea18 137 return await this.internalSendMessage(
08f130a0 138 chargingStation,
e7aeea18
JB
139 messageId,
140 messagePayload,
141 MessageType.CALL_MESSAGE,
142 commandName,
66a7748d
JB
143 params
144 )
5e0c67e8 145 } catch (error) {
fa5995d6 146 handleSendMessageError(chargingStation, commandName, error as Error, {
66a7748d
JB
147 throwError: params.throwError
148 })
149 return null
5e0c67e8
JB
150 }
151 }
152
291b5ec8 153 private validateRequestPayload<T extends JsonType>(
b52c969d 154 chargingStation: ChargingStation,
45988780 155 commandName: RequestCommand | IncomingRequestCommand,
66a7748d 156 payload: T
b52c969d 157 ): boolean {
5398cecf 158 if (chargingStation.stationInfo?.ocppStrictCompliance === false) {
66a7748d 159 return true
b52c969d 160 }
24d15716 161 if (!this.jsonSchemasValidateFunction.has(commandName as RequestCommand)) {
b3fc3ff5 162 logger.warn(
66a7748d
JB
163 `${chargingStation.logPrefix()} ${moduleName}.validateRequestPayload: No JSON schema found for command '${commandName}' PDU validation`
164 )
165 return true
45988780 166 }
24d15716 167 const validate = this.jsonSchemasValidateFunction.get(commandName as RequestCommand)
40615072 168 payload = clone<T>(payload)
66a7748d 169 OCPPServiceUtils.convertDateToISOString<T>(payload)
24d15716 170 if (validate?.(payload) === true) {
66a7748d 171 return true
b52c969d
JB
172 }
173 logger.error(
45988780 174 `${chargingStation.logPrefix()} ${moduleName}.validateRequestPayload: Command '${commandName}' request PDU is invalid: %j`,
24d15716 175 validate?.errors
66a7748d 176 )
e909d2a7 177 // OCPPError usage here is debatable: it's an error in the OCPP stack but not targeted to sendError().
b52c969d 178 throw new OCPPError(
24d15716 179 OCPPServiceUtils.ajvErrorsToErrorType(validate?.errors),
b52c969d
JB
180 'Request PDU is invalid',
181 commandName,
24d15716 182 JSON.stringify(validate?.errors, undefined, 2)
66a7748d 183 )
b52c969d
JB
184 }
185
291b5ec8 186 private validateIncomingRequestResponsePayload<T extends JsonType>(
b3fc3ff5
JB
187 chargingStation: ChargingStation,
188 commandName: RequestCommand | IncomingRequestCommand,
66a7748d 189 payload: T
b3fc3ff5 190 ): boolean {
5398cecf 191 if (chargingStation.stationInfo?.ocppStrictCompliance === false) {
66a7748d 192 return true
b3fc3ff5
JB
193 }
194 if (
24d15716 195 !this.ocppResponseService.jsonSchemasIncomingRequestResponseValidateFunction.has(
66a7748d
JB
196 commandName as IncomingRequestCommand
197 )
b3fc3ff5
JB
198 ) {
199 logger.warn(
24d15716 200 `${chargingStation.logPrefix()} ${moduleName}.validateIncomingRequestResponsePayload: No JSON schema validation function found for command '${commandName}' PDU validation`
66a7748d
JB
201 )
202 return true
b3fc3ff5 203 }
24d15716
JB
204 const validate =
205 this.ocppResponseService.jsonSchemasIncomingRequestResponseValidateFunction.get(
206 commandName as IncomingRequestCommand
207 )
40615072 208 payload = clone<T>(payload)
66a7748d 209 OCPPServiceUtils.convertDateToISOString<T>(payload)
24d15716 210 if (validate?.(payload) === true) {
66a7748d 211 return true
b3fc3ff5
JB
212 }
213 logger.error(
24d15716
JB
214 `${chargingStation.logPrefix()} ${moduleName}.validateIncomingRequestResponsePayload: Command '${commandName}' response PDU is invalid: %j`,
215 validate?.errors
66a7748d 216 )
b3fc3ff5
JB
217 // OCPPError usage here is debatable: it's an error in the OCPP stack but not targeted to sendError().
218 throw new OCPPError(
24d15716 219 OCPPServiceUtils.ajvErrorsToErrorType(validate?.errors),
b3fc3ff5
JB
220 'Response PDU is invalid',
221 commandName,
24d15716 222 JSON.stringify(validate?.errors, undefined, 2)
66a7748d 223 )
b3fc3ff5
JB
224 }
225
66a7748d 226 private async internalSendMessage (
08f130a0 227 chargingStation: ChargingStation,
e7aeea18 228 messageId: string,
5cc4b63b 229 messagePayload: JsonType | OCPPError,
e7aeea18 230 messageType: MessageType,
72092cfc 231 commandName: RequestCommand | IncomingRequestCommand,
66a7748d 232 params?: RequestParams
e7aeea18 233 ): Promise<ResponseType> {
7b5dbe91 234 params = {
b9da1bc2 235 ...defaultRequestParams,
66a7748d
JB
236 ...params
237 }
e7aeea18 238 if (
66a7748d 239 (chargingStation.inUnknownState() && commandName === RequestCommand.BOOT_NOTIFICATION) ||
5398cecf 240 (chargingStation.stationInfo?.ocppStrictCompliance === false &&
66a7748d
JB
241 chargingStation.inUnknownState()) ||
242 chargingStation.inAcceptedState() ||
243 (chargingStation.inPendingState() &&
3a13fc92 244 (params.triggerMessage === true || messageType === MessageType.CALL_RESULT_MESSAGE))
e7aeea18 245 ) {
caad9d6b 246 // eslint-disable-next-line @typescript-eslint/no-this-alias
66a7748d 247 const self = this
caad9d6b 248 // Send a message through wsConnection
66a7748d 249 return await new Promise<ResponseType>((resolve, reject) => {
1b2acf4e
JB
250 /**
251 * Function that will receive the request's response
252 *
253 * @param payload -
254 * @param requestPayload -
255 */
256 const responseCallback = (payload: JsonType, requestPayload: JsonType): void => {
257 if (chargingStation.stationInfo?.enableStatistics === true) {
258 chargingStation.performanceStatistics?.addRequestStatistic(
259 commandName,
66a7748d
JB
260 MessageType.CALL_RESULT_MESSAGE
261 )
1b2acf4e
JB
262 }
263 // Handle the request's response
264 self.ocppResponseService
265 .responseHandler(
266 chargingStation,
267 commandName as RequestCommand,
268 payload,
66a7748d 269 requestPayload
1b2acf4e
JB
270 )
271 .then(() => {
66a7748d 272 resolve(payload)
1b2acf4e 273 })
b7ee97c1 274 .catch(reject)
1b2acf4e 275 .finally(() => {
66a7748d
JB
276 chargingStation.requests.delete(messageId)
277 chargingStation.emit(ChargingStationEvents.updated)
278 })
279 }
e8a92d57 280
1b2acf4e
JB
281 /**
282 * Function that will receive the request's error response
283 *
9d7b5fa3 284 * @param ocppError -
1b2acf4e
JB
285 * @param requestStatistic -
286 */
9d7b5fa3 287 const errorCallback = (ocppError: OCPPError, requestStatistic = true): void => {
66a7748d 288 if (requestStatistic && chargingStation.stationInfo?.enableStatistics === true) {
1b2acf4e
JB
289 chargingStation.performanceStatistics?.addRequestStatistic(
290 commandName,
66a7748d
JB
291 MessageType.CALL_ERROR_MESSAGE
292 )
764d2c91 293 }
1b2acf4e
JB
294 logger.error(
295 `${chargingStation.logPrefix()} Error occurred at ${OCPPServiceUtils.getMessageTypeString(
66a7748d 296 messageType
1b2acf4e 297 )} command ${commandName} with PDU %j:`,
e7aeea18 298 messagePayload,
66a7748d
JB
299 ocppError
300 )
301 chargingStation.requests.delete(messageId)
302 chargingStation.emit(ChargingStationEvents.updated)
303 reject(ocppError)
304 }
9d7b5fa3 305
3c80de96
JB
306 const handleSendError = (ocppError: OCPPError): void => {
307 if (params?.skipBufferingOnError === false) {
308 // Buffer
66a7748d 309 chargingStation.bufferMessage(messageToSend)
3c80de96
JB
310 if (messageType === MessageType.CALL_MESSAGE) {
311 this.cacheRequestPromise(
312 chargingStation,
313 messageId,
314 messagePayload as JsonType,
315 commandName,
316 responseCallback,
66a7748d
JB
317 errorCallback
318 )
3c80de96 319 }
9aa1a33f
JB
320 } else if (
321 params?.skipBufferingOnError === true &&
322 messageType === MessageType.CALL_MESSAGE
323 ) {
2b94ad12 324 // Remove request from the cache
66a7748d 325 chargingStation.requests.delete(messageId)
d42379d8 326 }
66a7748d
JB
327 reject(ocppError)
328 }
d42379d8 329
1b2acf4e 330 if (chargingStation.stationInfo?.enableStatistics === true) {
66a7748d 331 chargingStation.performanceStatistics?.addRequestStatistic(commandName, messageType)
1b2acf4e
JB
332 }
333 const messageToSend = this.buildMessageToSend(
334 chargingStation,
335 messageId,
336 messagePayload,
337 messageType,
66a7748d
JB
338 commandName
339 )
1b2acf4e 340 // Check if wsConnection opened
66a7748d
JB
341 if (chargingStation.isWebSocketConnectionOpened()) {
342 const beginId = PerformanceStatistics.beginMeasure(commandName)
1a32c36b 343 const sendTimeout = setTimeout(() => {
66a7748d 344 handleSendError(
1a32c36b
JB
345 new OCPPError(
346 ErrorType.GENERIC_ERROR,
2b94ad12 347 `Timeout ${formatDurationMilliSeconds(
66a7748d 348 OCPPConstants.OCPP_WEBSOCKET_TIMEOUT
2b94ad12
JB
349 )} reached for ${
350 params?.skipBufferingOnError === false ? '' : 'non '
42b8cf5c 351 }buffered message id '${messageId}' with content '${messageToSend}'`,
1a32c36b 352 commandName,
66a7748d
JB
353 (messagePayload as OCPPError).details
354 )
355 )
356 }, OCPPConstants.OCPP_WEBSOCKET_TIMEOUT)
1a32c36b 357 chargingStation.wsConnection?.send(messageToSend, (error?: Error) => {
66a7748d
JB
358 PerformanceStatistics.endMeasure(commandName, beginId)
359 clearTimeout(sendTimeout)
aa63c9b7 360 if (error == null) {
d42379d8
JB
361 logger.debug(
362 `${chargingStation.logPrefix()} >> Command '${commandName}' sent ${OCPPServiceUtils.getMessageTypeString(
66a7748d
JB
363 messageType
364 )} payload: ${messageToSend}`
365 )
d42379d8
JB
366 if (messageType === MessageType.CALL_MESSAGE) {
367 this.cacheRequestPromise(
368 chargingStation,
369 messageId,
370 messagePayload as JsonType,
371 commandName,
372 responseCallback,
66a7748d
JB
373 errorCallback
374 )
69dae411
JB
375 } else {
376 // Resolve response
66a7748d 377 resolve(messagePayload)
d42379d8 378 }
5199f9fd 379 } else {
66a7748d 380 handleSendError(
3c80de96
JB
381 new OCPPError(
382 ErrorType.GENERIC_ERROR,
383 `WebSocket errored for ${
384 params?.skipBufferingOnError === false ? '' : 'non '
385 }buffered message id '${messageId}' with content '${messageToSend}'`,
386 commandName,
66a7748d
JB
387 { name: error.name, message: error.message, stack: error.stack }
388 )
389 )
1a32c36b 390 }
66a7748d 391 })
82fa1110 392 } else {
66a7748d 393 handleSendError(
3c80de96
JB
394 new OCPPError(
395 ErrorType.GENERIC_ERROR,
396 `WebSocket closed for ${
397 params?.skipBufferingOnError === false ? '' : 'non '
398 }buffered message id '${messageId}' with content '${messageToSend}'`,
399 commandName,
66a7748d
JB
400 (messagePayload as OCPPError).details
401 )
402 )
1b2acf4e 403 }
66a7748d 404 })
caad9d6b 405 }
e7aeea18
JB
406 throw new OCPPError(
407 ErrorType.SECURITY_ERROR,
5199f9fd 408 `Cannot send command ${commandName} PDU when the charging station is in ${chargingStation.bootNotificationResponse?.status} state on the central server`,
66a7748d
JB
409 commandName
410 )
c0560973
JB
411 }
412
66a7748d 413 private buildMessageToSend (
08f130a0 414 chargingStation: ChargingStation,
e7aeea18 415 messageId: string,
5cc4b63b 416 messagePayload: JsonType | OCPPError,
e7aeea18 417 messageType: MessageType,
66a7748d 418 commandName: RequestCommand | IncomingRequestCommand
e7aeea18 419 ): string {
66a7748d 420 let messageToSend: string
e7accadb
JB
421 // Type of message
422 switch (messageType) {
423 // Request
424 case MessageType.CALL_MESSAGE:
425 // Build request
66a7748d 426 this.validateRequestPayload(chargingStation, commandName, messagePayload as JsonType)
b3ec7bc1
JB
427 messageToSend = JSON.stringify([
428 messageType,
429 messageId,
7aba6c53
JB
430 commandName as RequestCommand,
431 messagePayload as JsonType
432 ] satisfies OutgoingRequest)
66a7748d 433 break
e7accadb
JB
434 // Response
435 case MessageType.CALL_RESULT_MESSAGE:
436 // Build response
02887891
JB
437 this.validateIncomingRequestResponsePayload(
438 chargingStation,
439 commandName,
66a7748d
JB
440 messagePayload as JsonType
441 )
7aba6c53
JB
442 messageToSend = JSON.stringify([
443 messageType,
444 messageId,
445 messagePayload as JsonType
446 ] satisfies Response)
66a7748d 447 break
e7accadb
JB
448 // Error Message
449 case MessageType.CALL_ERROR_MESSAGE:
450 // Build Error Message
e7aeea18
JB
451 messageToSend = JSON.stringify([
452 messageType,
453 messageId,
7375968c
JB
454 (messagePayload as OCPPError).code,
455 (messagePayload as OCPPError).message,
456 (messagePayload as OCPPError).details ?? {
66a7748d
JB
457 command: (messagePayload as OCPPError).command ?? commandName
458 }
7aba6c53 459 ] satisfies ErrorResponse)
66a7748d 460 break
e7accadb 461 }
66a7748d 462 return messageToSend
e7accadb
JB
463 }
464
66a7748d 465 private cacheRequestPromise (
82fa1110 466 chargingStation: ChargingStation,
82fa1110
JB
467 messageId: string,
468 messagePayload: JsonType,
469 commandName: RequestCommand | IncomingRequestCommand,
54a8fbc7 470 responseCallback: ResponseCallback,
66a7748d 471 errorCallback: ErrorCallback
82fa1110
JB
472 ): void {
473 chargingStation.requests.set(messageId, [
474 responseCallback,
475 errorCallback,
476 commandName,
66a7748d
JB
477 messagePayload
478 ])
82fa1110
JB
479 }
480
ef6fa3fb 481 // eslint-disable-next-line @typescript-eslint/no-unused-vars
e0b0ee21 482 public abstract requestHandler<ReqType extends JsonType, ResType extends JsonType>(
08f130a0 483 chargingStation: ChargingStation,
94a464f9 484 commandName: RequestCommand,
e1d9a0f4 485 // FIXME: should be ReqType
5cc4b63b 486 commandParams?: JsonType,
66a7748d
JB
487 params?: RequestParams
488 ): Promise<ResType>
c0560973 489}