3ac768c2052c9468fae03c2a30a4cb8fea0e99b8
[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 { name: error.name, message: error.message, stack: error.stack }
387 )
388 )
389 }
390 })
391 } else {
392 handleSendError(
393 new OCPPError(
394 ErrorType.GENERIC_ERROR,
395 `WebSocket closed for ${
396 params.skipBufferingOnError === false ? '' : 'non '
397 }buffered message id '${messageId}' with content '${messageToSend}'`,
398 commandName,
399 (messagePayload as OCPPError).details
400 )
401 )
402 }
403 })
404 }
405 throw new OCPPError(
406 ErrorType.SECURITY_ERROR,
407 `Cannot send command ${commandName} PDU when the charging station is in ${chargingStation.bootNotificationResponse?.status} state on the central server`,
408 commandName
409 )
410 }
411
412 private buildMessageToSend (
413 chargingStation: ChargingStation,
414 messageId: string,
415 messagePayload: JsonType | OCPPError,
416 messageType: MessageType,
417 commandName: RequestCommand | IncomingRequestCommand
418 ): string {
419 let messageToSend: string
420 // Type of message
421 switch (messageType) {
422 // Request
423 case MessageType.CALL_MESSAGE:
424 // Build request
425 this.validateRequestPayload(chargingStation, commandName, messagePayload as JsonType)
426 messageToSend = JSON.stringify([
427 messageType,
428 messageId,
429 commandName as RequestCommand,
430 messagePayload as JsonType
431 ] satisfies OutgoingRequest)
432 break
433 // Response
434 case MessageType.CALL_RESULT_MESSAGE:
435 // Build response
436 this.validateIncomingRequestResponsePayload(
437 chargingStation,
438 commandName,
439 messagePayload as JsonType
440 )
441 messageToSend = JSON.stringify([
442 messageType,
443 messageId,
444 messagePayload as JsonType
445 ] satisfies Response)
446 break
447 // Error Message
448 case MessageType.CALL_ERROR_MESSAGE:
449 // Build Error Message
450 messageToSend = JSON.stringify([
451 messageType,
452 messageId,
453 (messagePayload as OCPPError).code,
454 (messagePayload as OCPPError).message,
455 (messagePayload as OCPPError).details ?? {
456 command: (messagePayload as OCPPError).command
457 }
458 ] satisfies ErrorResponse)
459 break
460 }
461 return messageToSend
462 }
463
464 private setCachedRequest (
465 chargingStation: ChargingStation,
466 messageId: string,
467 messagePayload: JsonType,
468 commandName: RequestCommand | IncomingRequestCommand,
469 responseCallback: ResponseCallback,
470 errorCallback: ErrorCallback
471 ): void {
472 chargingStation.requests.set(messageId, [
473 responseCallback,
474 errorCallback,
475 commandName,
476 messagePayload
477 ])
478 }
479
480 // eslint-disable-next-line @typescript-eslint/no-unused-vars
481 public abstract requestHandler<ReqType extends JsonType, ResType extends JsonType>(
482 chargingStation: ChargingStation,
483 commandName: RequestCommand,
484 commandParams?: ReqType,
485 params?: RequestParams
486 ): Promise<ResType>
487 }