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