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