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