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