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