refactor: cleanup boot notification response handling
[e-mobility-charging-stations-simulator.git] / src / charging-station / Helpers.ts
1 import { createHash, randomBytes } from 'node:crypto'
2 import type { EventEmitter } from 'node:events'
3 import { basename, dirname, isAbsolute, join, parse, relative, resolve } from 'node:path'
4 import { env } from 'node:process'
5 import { fileURLToPath } from 'node:url'
6
7 import chalk from 'chalk'
8 import {
9 addDays,
10 addSeconds,
11 addWeeks,
12 differenceInDays,
13 differenceInSeconds,
14 differenceInWeeks,
15 type Interval,
16 isAfter,
17 isBefore,
18 isDate,
19 isPast,
20 isWithinInterval,
21 toDate
22 } from 'date-fns'
23 import { maxTime } from 'date-fns/constants'
24 import { isEmpty } from 'rambda'
25
26 import { BaseError } from '../exception/index.js'
27 import {
28 AmpereUnits,
29 AvailabilityType,
30 type BootNotificationRequest,
31 BootReasonEnumType,
32 type ChargingProfile,
33 ChargingProfileKindType,
34 ChargingRateUnitType,
35 type ChargingSchedulePeriod,
36 type ChargingStationConfiguration,
37 type ChargingStationInfo,
38 type ChargingStationOptions,
39 type ChargingStationTemplate,
40 type ChargingStationWorkerMessageEvents,
41 ConnectorPhaseRotation,
42 type ConnectorStatus,
43 ConnectorStatusEnum,
44 CurrentType,
45 type EvseTemplate,
46 type OCPP16BootNotificationRequest,
47 type OCPP20BootNotificationRequest,
48 OCPPVersion,
49 RecurrencyKindType,
50 type Reservation,
51 ReservationTerminationReason,
52 StandardParametersKey,
53 type SupportedFeatureProfiles,
54 Voltage
55 } from '../types/index.js'
56 import {
57 ACElectricUtils,
58 clone,
59 Constants,
60 convertToDate,
61 convertToInt,
62 DCElectricUtils,
63 isArraySorted,
64 isNotEmptyArray,
65 isNotEmptyString,
66 isValidDate,
67 logger,
68 secureRandom
69 } from '../utils/index.js'
70 import type { ChargingStation } from './ChargingStation.js'
71 import { getConfigurationKey } from './ConfigurationKeyUtils.js'
72
73 const moduleName = 'Helpers'
74
75 export const buildTemplateName = (templateFile: string): string => {
76 if (isAbsolute(templateFile)) {
77 templateFile = relative(
78 resolve(join(dirname(fileURLToPath(import.meta.url)), 'assets', 'station-templates')),
79 templateFile
80 )
81 }
82 const templateFileParsedPath = parse(templateFile)
83 return join(templateFileParsedPath.dir, templateFileParsedPath.name)
84 }
85
86 export const getChargingStationId = (
87 index: number,
88 stationTemplate: ChargingStationTemplate | undefined
89 ): string => {
90 if (stationTemplate == null) {
91 return "Unknown 'chargingStationId'"
92 }
93 // In case of multiple instances: add instance index to charging station id
94 const instanceIndex = env.CF_INSTANCE_INDEX ?? 0
95 const idSuffix = stationTemplate.nameSuffix ?? ''
96 const idStr = `000000000${index.toString()}`
97 return stationTemplate.fixedName === true
98 ? stationTemplate.baseName
99 : `${stationTemplate.baseName}-${instanceIndex.toString()}${idStr.substring(
100 idStr.length - 4
101 )}${idSuffix}`
102 }
103
104 export const hasReservationExpired = (reservation: Reservation): boolean => {
105 return isPast(reservation.expiryDate)
106 }
107
108 export const removeExpiredReservations = async (
109 chargingStation: ChargingStation
110 ): Promise<void> => {
111 if (chargingStation.hasEvses) {
112 for (const evseStatus of chargingStation.evses.values()) {
113 for (const connectorStatus of evseStatus.connectors.values()) {
114 if (
115 connectorStatus.reservation != null &&
116 hasReservationExpired(connectorStatus.reservation)
117 ) {
118 await chargingStation.removeReservation(
119 connectorStatus.reservation,
120 ReservationTerminationReason.EXPIRED
121 )
122 }
123 }
124 }
125 } else {
126 for (const connectorStatus of chargingStation.connectors.values()) {
127 if (
128 connectorStatus.reservation != null &&
129 hasReservationExpired(connectorStatus.reservation)
130 ) {
131 await chargingStation.removeReservation(
132 connectorStatus.reservation,
133 ReservationTerminationReason.EXPIRED
134 )
135 }
136 }
137 }
138 }
139
140 export const getNumberOfReservableConnectors = (
141 connectors: Map<number, ConnectorStatus>
142 ): number => {
143 let numberOfReservableConnectors = 0
144 for (const [connectorId, connectorStatus] of connectors) {
145 if (connectorId === 0) {
146 continue
147 }
148 if (connectorStatus.status === ConnectorStatusEnum.Available) {
149 ++numberOfReservableConnectors
150 }
151 }
152 return numberOfReservableConnectors
153 }
154
155 export const getHashId = (index: number, stationTemplate: ChargingStationTemplate): string => {
156 const chargingStationInfo = {
157 chargePointModel: stationTemplate.chargePointModel,
158 chargePointVendor: stationTemplate.chargePointVendor,
159 ...(stationTemplate.chargeBoxSerialNumberPrefix != null && {
160 chargeBoxSerialNumber: stationTemplate.chargeBoxSerialNumberPrefix
161 }),
162 ...(stationTemplate.chargePointSerialNumberPrefix != null && {
163 chargePointSerialNumber: stationTemplate.chargePointSerialNumberPrefix
164 }),
165 ...(stationTemplate.meterSerialNumberPrefix != null && {
166 meterSerialNumber: stationTemplate.meterSerialNumberPrefix
167 }),
168 ...(stationTemplate.meterType != null && {
169 meterType: stationTemplate.meterType
170 })
171 }
172 return createHash(Constants.DEFAULT_HASH_ALGORITHM)
173 .update(`${JSON.stringify(chargingStationInfo)}${getChargingStationId(index, stationTemplate)}`)
174 .digest('hex')
175 }
176
177 export const checkChargingStation = (
178 chargingStation: ChargingStation,
179 logPrefix: string
180 ): boolean => {
181 if (!chargingStation.started && !chargingStation.starting) {
182 logger.warn(`${logPrefix} charging station is stopped, cannot proceed`)
183 return false
184 }
185 return true
186 }
187
188 export const getPhaseRotationValue = (
189 connectorId: number,
190 numberOfPhases: number
191 ): string | undefined => {
192 // AC/DC
193 if (connectorId === 0 && numberOfPhases === 0) {
194 return `${connectorId}.${ConnectorPhaseRotation.RST}`
195 } else if (connectorId > 0 && numberOfPhases === 0) {
196 return `${connectorId}.${ConnectorPhaseRotation.NotApplicable}`
197 // AC
198 } else if (connectorId >= 0 && numberOfPhases === 1) {
199 return `${connectorId}.${ConnectorPhaseRotation.NotApplicable}`
200 } else if (connectorId >= 0 && numberOfPhases === 3) {
201 return `${connectorId}.${ConnectorPhaseRotation.RST}`
202 }
203 }
204
205 export const getMaxNumberOfEvses = (evses: Record<string, EvseTemplate> | undefined): number => {
206 if (evses == null) {
207 return -1
208 }
209 return Object.keys(evses).length
210 }
211
212 const getMaxNumberOfConnectors = (
213 connectors: Record<string, ConnectorStatus> | undefined
214 ): number => {
215 if (connectors == null) {
216 return -1
217 }
218 return Object.keys(connectors).length
219 }
220
221 export const getBootConnectorStatus = (
222 chargingStation: ChargingStation,
223 connectorId: number,
224 connectorStatus: ConnectorStatus
225 ): ConnectorStatusEnum => {
226 let connectorBootStatus: ConnectorStatusEnum
227 if (
228 connectorStatus.status == null &&
229 (!chargingStation.isChargingStationAvailable() ||
230 !chargingStation.isConnectorAvailable(connectorId))
231 ) {
232 connectorBootStatus = ConnectorStatusEnum.Unavailable
233 } else if (connectorStatus.status == null && connectorStatus.bootStatus != null) {
234 // Set boot status in template at startup
235 connectorBootStatus = connectorStatus.bootStatus
236 } else if (connectorStatus.status != null) {
237 // Set previous status at startup
238 connectorBootStatus = connectorStatus.status
239 } else {
240 // Set default status
241 connectorBootStatus = ConnectorStatusEnum.Available
242 }
243 return connectorBootStatus
244 }
245
246 export const checkTemplate = (
247 stationTemplate: ChargingStationTemplate | undefined,
248 logPrefix: string,
249 templateFile: string
250 ): void => {
251 if (stationTemplate == null) {
252 const errorMsg = `Failed to read charging station template file ${templateFile}`
253 logger.error(`${logPrefix} ${errorMsg}`)
254 throw new BaseError(errorMsg)
255 }
256 if (isEmpty(stationTemplate)) {
257 const errorMsg = `Empty charging station information from template file ${templateFile}`
258 logger.error(`${logPrefix} ${errorMsg}`)
259 throw new BaseError(errorMsg)
260 }
261 if (stationTemplate.idTagsFile == null || isEmpty(stationTemplate.idTagsFile)) {
262 logger.warn(
263 `${logPrefix} Missing id tags file in template file ${templateFile}. That can lead to issues with the Automatic Transaction Generator`
264 )
265 }
266 }
267
268 export const checkConfiguration = (
269 stationConfiguration: ChargingStationConfiguration | undefined,
270 logPrefix: string,
271 configurationFile: string
272 ): void => {
273 if (stationConfiguration == null) {
274 const errorMsg = `Failed to read charging station configuration file ${configurationFile}`
275 logger.error(`${logPrefix} ${errorMsg}`)
276 throw new BaseError(errorMsg)
277 }
278 if (isEmpty(stationConfiguration)) {
279 const errorMsg = `Empty charging station configuration from file ${configurationFile}`
280 logger.error(`${logPrefix} ${errorMsg}`)
281 throw new BaseError(errorMsg)
282 }
283 }
284
285 export const checkConnectorsConfiguration = (
286 stationTemplate: ChargingStationTemplate,
287 logPrefix: string,
288 templateFile: string
289 ): {
290 configuredMaxConnectors: number
291 templateMaxConnectors: number
292 templateMaxAvailableConnectors: number
293 } => {
294 const configuredMaxConnectors = getConfiguredMaxNumberOfConnectors(stationTemplate)
295 checkConfiguredMaxConnectors(configuredMaxConnectors, logPrefix, templateFile)
296 const templateMaxConnectors = getMaxNumberOfConnectors(stationTemplate.Connectors)
297 checkTemplateMaxConnectors(templateMaxConnectors, logPrefix, templateFile)
298 const templateMaxAvailableConnectors =
299 stationTemplate.Connectors?.[0] != null ? templateMaxConnectors - 1 : templateMaxConnectors
300 if (
301 configuredMaxConnectors > templateMaxAvailableConnectors &&
302 stationTemplate.randomConnectors !== true
303 ) {
304 logger.warn(
305 `${logPrefix} Number of connectors exceeds the number of connector configurations in template ${templateFile}, forcing random connector configurations affectation`
306 )
307 stationTemplate.randomConnectors = true
308 }
309 return {
310 configuredMaxConnectors,
311 templateMaxConnectors,
312 templateMaxAvailableConnectors
313 }
314 }
315
316 export const checkStationInfoConnectorStatus = (
317 connectorId: number,
318 connectorStatus: ConnectorStatus,
319 logPrefix: string,
320 templateFile: string
321 ): void => {
322 if (connectorStatus.status != null) {
323 logger.warn(
324 `${logPrefix} Charging station information from template ${templateFile} with connector id ${connectorId} status configuration defined, undefine it`
325 )
326 delete connectorStatus.status
327 }
328 }
329
330 export const buildConnectorsMap = (
331 connectors: Record<string, ConnectorStatus>,
332 logPrefix: string,
333 templateFile: string
334 ): Map<number, ConnectorStatus> => {
335 const connectorsMap = new Map<number, ConnectorStatus>()
336 if (getMaxNumberOfConnectors(connectors) > 0) {
337 for (const connector in connectors) {
338 const connectorStatus = connectors[connector]
339 const connectorId = convertToInt(connector)
340 checkStationInfoConnectorStatus(connectorId, connectorStatus, logPrefix, templateFile)
341 connectorsMap.set(connectorId, clone<ConnectorStatus>(connectorStatus))
342 }
343 } else {
344 logger.warn(
345 `${logPrefix} Charging station information from template ${templateFile} with no connectors, cannot build connectors map`
346 )
347 }
348 return connectorsMap
349 }
350
351 export const setChargingStationOptions = (
352 stationInfo: ChargingStationInfo,
353 options?: ChargingStationOptions
354 ): ChargingStationInfo => {
355 if (options?.supervisionUrls != null) {
356 stationInfo.supervisionUrls = options.supervisionUrls
357 }
358 if (options?.persistentConfiguration != null) {
359 stationInfo.stationInfoPersistentConfiguration = options.persistentConfiguration
360 stationInfo.ocppPersistentConfiguration = options.persistentConfiguration
361 stationInfo.automaticTransactionGeneratorPersistentConfiguration =
362 options.persistentConfiguration
363 }
364 if (options?.autoStart != null) {
365 stationInfo.autoStart = options.autoStart
366 }
367 if (options?.autoRegister != null) {
368 stationInfo.autoRegister = options.autoRegister
369 }
370 if (options?.enableStatistics != null) {
371 stationInfo.enableStatistics = options.enableStatistics
372 }
373 if (options?.ocppStrictCompliance != null) {
374 stationInfo.ocppStrictCompliance = options.ocppStrictCompliance
375 }
376 if (options?.stopTransactionsOnStopped != null) {
377 stationInfo.stopTransactionsOnStopped = options.stopTransactionsOnStopped
378 }
379 return stationInfo
380 }
381
382 export const initializeConnectorsMapStatus = (
383 connectors: Map<number, ConnectorStatus>,
384 logPrefix: string
385 ): void => {
386 for (const connectorId of connectors.keys()) {
387 if (connectorId > 0 && connectors.get(connectorId)?.transactionStarted === true) {
388 logger.warn(
389 `${logPrefix} Connector id ${connectorId} at initialization has a transaction started with id ${
390 connectors.get(connectorId)?.transactionId
391 }`
392 )
393 }
394 if (connectorId === 0) {
395 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
396 connectors.get(connectorId)!.availability = AvailabilityType.Operative
397 if (connectors.get(connectorId)?.chargingProfiles == null) {
398 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
399 connectors.get(connectorId)!.chargingProfiles = []
400 }
401 } else if (connectorId > 0 && connectors.get(connectorId)?.transactionStarted == null) {
402 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
403 initializeConnectorStatus(connectors.get(connectorId)!)
404 }
405 }
406 }
407
408 export const resetAuthorizeConnectorStatus = (connectorStatus: ConnectorStatus): void => {
409 connectorStatus.idTagLocalAuthorized = false
410 connectorStatus.idTagAuthorized = false
411 delete connectorStatus.localAuthorizeIdTag
412 delete connectorStatus.authorizeIdTag
413 }
414
415 export const resetConnectorStatus = (connectorStatus: ConnectorStatus | undefined): void => {
416 if (connectorStatus == null) {
417 return
418 }
419 if (isNotEmptyArray(connectorStatus.chargingProfiles)) {
420 connectorStatus.chargingProfiles = connectorStatus.chargingProfiles.filter(
421 chargingProfile =>
422 (chargingProfile.transactionId != null &&
423 connectorStatus.transactionId != null &&
424 chargingProfile.transactionId !== connectorStatus.transactionId) ||
425 chargingProfile.transactionId == null
426 )
427 }
428 resetAuthorizeConnectorStatus(connectorStatus)
429 connectorStatus.transactionRemoteStarted = false
430 connectorStatus.transactionStarted = false
431 delete connectorStatus.transactionStart
432 delete connectorStatus.transactionId
433 delete connectorStatus.transactionIdTag
434 connectorStatus.transactionEnergyActiveImportRegisterValue = 0
435 delete connectorStatus.transactionBeginMeterValue
436 }
437
438 export const createBootNotificationRequest = (
439 stationInfo: ChargingStationInfo,
440 bootReason: BootReasonEnumType = BootReasonEnumType.PowerUp
441 ): BootNotificationRequest | undefined => {
442 const ocppVersion = stationInfo.ocppVersion
443 switch (ocppVersion) {
444 case OCPPVersion.VERSION_16:
445 return {
446 chargePointModel: stationInfo.chargePointModel,
447 chargePointVendor: stationInfo.chargePointVendor,
448 ...(stationInfo.chargeBoxSerialNumber != null && {
449 chargeBoxSerialNumber: stationInfo.chargeBoxSerialNumber
450 }),
451 ...(stationInfo.chargePointSerialNumber != null && {
452 chargePointSerialNumber: stationInfo.chargePointSerialNumber
453 }),
454 ...(stationInfo.firmwareVersion != null && {
455 firmwareVersion: stationInfo.firmwareVersion
456 }),
457 ...(stationInfo.iccid != null && { iccid: stationInfo.iccid }),
458 ...(stationInfo.imsi != null && { imsi: stationInfo.imsi }),
459 ...(stationInfo.meterSerialNumber != null && {
460 meterSerialNumber: stationInfo.meterSerialNumber
461 }),
462 ...(stationInfo.meterType != null && {
463 meterType: stationInfo.meterType
464 })
465 } satisfies OCPP16BootNotificationRequest
466 case OCPPVersion.VERSION_20:
467 case OCPPVersion.VERSION_201:
468 return {
469 reason: bootReason,
470 chargingStation: {
471 model: stationInfo.chargePointModel,
472 vendorName: stationInfo.chargePointVendor,
473 ...(stationInfo.firmwareVersion != null && {
474 firmwareVersion: stationInfo.firmwareVersion
475 }),
476 ...(stationInfo.chargeBoxSerialNumber != null && {
477 serialNumber: stationInfo.chargeBoxSerialNumber
478 }),
479 ...((stationInfo.iccid != null || stationInfo.imsi != null) && {
480 modem: {
481 ...(stationInfo.iccid != null && { iccid: stationInfo.iccid }),
482 ...(stationInfo.imsi != null && { imsi: stationInfo.imsi })
483 }
484 })
485 }
486 } satisfies OCPP20BootNotificationRequest
487 }
488 }
489
490 export const warnTemplateKeysDeprecation = (
491 stationTemplate: ChargingStationTemplate,
492 logPrefix: string,
493 templateFile: string
494 ): void => {
495 const templateKeys: Array<{ deprecatedKey: string, key?: string }> = [
496 { deprecatedKey: 'supervisionUrl', key: 'supervisionUrls' },
497 { deprecatedKey: 'authorizationFile', key: 'idTagsFile' },
498 { deprecatedKey: 'payloadSchemaValidation', key: 'ocppStrictCompliance' },
499 { deprecatedKey: 'mustAuthorizeAtRemoteStart', key: 'remoteAuthorization' }
500 ]
501 for (const templateKey of templateKeys) {
502 warnDeprecatedTemplateKey(
503 stationTemplate,
504 templateKey.deprecatedKey,
505 logPrefix,
506 templateFile,
507 templateKey.key != null ? `Use '${templateKey.key}' instead` : undefined
508 )
509 convertDeprecatedTemplateKey(stationTemplate, templateKey.deprecatedKey, templateKey.key)
510 }
511 }
512
513 export const stationTemplateToStationInfo = (
514 stationTemplate: ChargingStationTemplate
515 ): ChargingStationInfo => {
516 stationTemplate = clone<ChargingStationTemplate>(stationTemplate)
517 delete stationTemplate.power
518 delete stationTemplate.powerUnit
519 delete stationTemplate.Connectors
520 delete stationTemplate.Evses
521 delete stationTemplate.Configuration
522 delete stationTemplate.AutomaticTransactionGenerator
523 delete stationTemplate.numberOfConnectors
524 delete stationTemplate.chargeBoxSerialNumberPrefix
525 delete stationTemplate.chargePointSerialNumberPrefix
526 delete stationTemplate.meterSerialNumberPrefix
527 return stationTemplate as ChargingStationInfo
528 }
529
530 export const createSerialNumber = (
531 stationTemplate: ChargingStationTemplate,
532 stationInfo: ChargingStationInfo,
533 params?: {
534 randomSerialNumberUpperCase?: boolean
535 randomSerialNumber?: boolean
536 }
537 ): void => {
538 params = {
539 ...{ randomSerialNumberUpperCase: true, randomSerialNumber: true },
540 ...params
541 }
542 const serialNumberSuffix =
543 params.randomSerialNumber === true
544 ? getRandomSerialNumberSuffix({
545 upperCase: params.randomSerialNumberUpperCase
546 })
547 : ''
548 isNotEmptyString(stationTemplate.chargePointSerialNumberPrefix) &&
549 (stationInfo.chargePointSerialNumber = `${stationTemplate.chargePointSerialNumberPrefix}${serialNumberSuffix}`)
550 isNotEmptyString(stationTemplate.chargeBoxSerialNumberPrefix) &&
551 (stationInfo.chargeBoxSerialNumber = `${stationTemplate.chargeBoxSerialNumberPrefix}${serialNumberSuffix}`)
552 isNotEmptyString(stationTemplate.meterSerialNumberPrefix) &&
553 (stationInfo.meterSerialNumber = `${stationTemplate.meterSerialNumberPrefix}${serialNumberSuffix}`)
554 }
555
556 export const propagateSerialNumber = (
557 stationTemplate: ChargingStationTemplate | undefined,
558 stationInfoSrc: ChargingStationInfo | undefined,
559 stationInfoDst: ChargingStationInfo
560 ): void => {
561 if (stationInfoSrc == null || stationTemplate == null) {
562 throw new BaseError(
563 'Missing charging station template or existing configuration to propagate serial number'
564 )
565 }
566 stationTemplate.chargePointSerialNumberPrefix != null &&
567 stationInfoSrc.chargePointSerialNumber != null
568 ? (stationInfoDst.chargePointSerialNumber = stationInfoSrc.chargePointSerialNumber)
569 : stationInfoDst.chargePointSerialNumber != null &&
570 delete stationInfoDst.chargePointSerialNumber
571 stationTemplate.chargeBoxSerialNumberPrefix != null &&
572 stationInfoSrc.chargeBoxSerialNumber != null
573 ? (stationInfoDst.chargeBoxSerialNumber = stationInfoSrc.chargeBoxSerialNumber)
574 : stationInfoDst.chargeBoxSerialNumber != null && delete stationInfoDst.chargeBoxSerialNumber
575 stationTemplate.meterSerialNumberPrefix != null && stationInfoSrc.meterSerialNumber != null
576 ? (stationInfoDst.meterSerialNumber = stationInfoSrc.meterSerialNumber)
577 : stationInfoDst.meterSerialNumber != null && delete stationInfoDst.meterSerialNumber
578 }
579
580 export const hasFeatureProfile = (
581 chargingStation: ChargingStation,
582 featureProfile: SupportedFeatureProfiles
583 ): boolean | undefined => {
584 return getConfigurationKey(
585 chargingStation,
586 StandardParametersKey.SupportedFeatureProfiles
587 )?.value?.includes(featureProfile)
588 }
589
590 export const getAmperageLimitationUnitDivider = (stationInfo: ChargingStationInfo): number => {
591 let unitDivider = 1
592 switch (stationInfo.amperageLimitationUnit) {
593 case AmpereUnits.DECI_AMPERE:
594 unitDivider = 10
595 break
596 case AmpereUnits.CENTI_AMPERE:
597 unitDivider = 100
598 break
599 case AmpereUnits.MILLI_AMPERE:
600 unitDivider = 1000
601 break
602 }
603 return unitDivider
604 }
605
606 /**
607 * Gets the connector cloned charging profiles applying a power limitation
608 * and sorted by connector id descending then stack level descending
609 *
610 * @param chargingStation -
611 * @param connectorId -
612 * @returns connector charging profiles array
613 */
614 export const getConnectorChargingProfiles = (
615 chargingStation: ChargingStation,
616 connectorId: number
617 ): ChargingProfile[] => {
618 return clone<ChargingProfile[]>(
619 (chargingStation.getConnectorStatus(connectorId)?.chargingProfiles ?? [])
620 .sort((a, b) => b.stackLevel - a.stackLevel)
621 .concat(
622 (chargingStation.getConnectorStatus(0)?.chargingProfiles ?? []).sort(
623 (a, b) => b.stackLevel - a.stackLevel
624 )
625 )
626 )
627 }
628
629 export const getChargingStationConnectorChargingProfilesPowerLimit = (
630 chargingStation: ChargingStation,
631 connectorId: number
632 ): number | undefined => {
633 let limit: number | undefined, chargingProfile: ChargingProfile | undefined
634 // Get charging profiles sorted by connector id then stack level
635 const chargingProfiles = getConnectorChargingProfiles(chargingStation, connectorId)
636 if (isNotEmptyArray(chargingProfiles)) {
637 const result = getLimitFromChargingProfiles(
638 chargingStation,
639 connectorId,
640 chargingProfiles,
641 chargingStation.logPrefix()
642 )
643 if (result != null) {
644 limit = result.limit
645 chargingProfile = result.chargingProfile
646 switch (chargingStation.stationInfo?.currentOutType) {
647 case CurrentType.AC:
648 limit =
649 chargingProfile.chargingSchedule.chargingRateUnit === ChargingRateUnitType.WATT
650 ? limit
651 : ACElectricUtils.powerTotal(
652 chargingStation.getNumberOfPhases(),
653 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
654 chargingStation.stationInfo.voltageOut!,
655 limit
656 )
657 break
658 case CurrentType.DC:
659 limit =
660 chargingProfile.chargingSchedule.chargingRateUnit === ChargingRateUnitType.WATT
661 ? limit
662 : // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
663 DCElectricUtils.power(chargingStation.stationInfo.voltageOut!, limit)
664 }
665 const connectorMaximumPower =
666 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
667 chargingStation.stationInfo!.maximumPower! / chargingStation.powerDivider!
668 if (limit > connectorMaximumPower) {
669 logger.error(
670 `${chargingStation.logPrefix()} ${moduleName}.getChargingStationConnectorChargingProfilesPowerLimit: Charging profile id ${
671 chargingProfile.chargingProfileId
672 } limit ${limit} is greater than connector id ${connectorId} maximum ${connectorMaximumPower}: %j`,
673 result
674 )
675 limit = connectorMaximumPower
676 }
677 }
678 }
679 return limit
680 }
681
682 export const getDefaultVoltageOut = (
683 currentType: CurrentType,
684 logPrefix: string,
685 templateFile: string
686 ): Voltage => {
687 const errorMsg = `Unknown ${currentType} currentOutType in template file ${templateFile}, cannot define default voltage out`
688 let defaultVoltageOut: number
689 switch (currentType) {
690 case CurrentType.AC:
691 defaultVoltageOut = Voltage.VOLTAGE_230
692 break
693 case CurrentType.DC:
694 defaultVoltageOut = Voltage.VOLTAGE_400
695 break
696 default:
697 logger.error(`${logPrefix} ${errorMsg}`)
698 throw new BaseError(errorMsg)
699 }
700 return defaultVoltageOut
701 }
702
703 export const getIdTagsFile = (stationInfo: ChargingStationInfo): string | undefined => {
704 return stationInfo.idTagsFile != null
705 ? join(dirname(fileURLToPath(import.meta.url)), 'assets', basename(stationInfo.idTagsFile))
706 : undefined
707 }
708
709 export const waitChargingStationEvents = async (
710 emitter: EventEmitter,
711 event: ChargingStationWorkerMessageEvents,
712 eventsToWait: number
713 ): Promise<number> => {
714 return await new Promise<number>(resolve => {
715 let events = 0
716 if (eventsToWait === 0) {
717 resolve(events)
718 return
719 }
720 emitter.on(event, () => {
721 ++events
722 if (events === eventsToWait) {
723 resolve(events)
724 }
725 })
726 })
727 }
728
729 const getConfiguredMaxNumberOfConnectors = (stationTemplate: ChargingStationTemplate): number => {
730 let configuredMaxNumberOfConnectors = 0
731 if (isNotEmptyArray(stationTemplate.numberOfConnectors)) {
732 const numberOfConnectors = stationTemplate.numberOfConnectors
733 configuredMaxNumberOfConnectors =
734 numberOfConnectors[Math.floor(secureRandom() * numberOfConnectors.length)]
735 } else if (stationTemplate.numberOfConnectors != null) {
736 configuredMaxNumberOfConnectors = stationTemplate.numberOfConnectors
737 } else if (stationTemplate.Connectors != null && stationTemplate.Evses == null) {
738 configuredMaxNumberOfConnectors =
739 // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
740 stationTemplate.Connectors[0] != null
741 ? getMaxNumberOfConnectors(stationTemplate.Connectors) - 1
742 : getMaxNumberOfConnectors(stationTemplate.Connectors)
743 } else if (stationTemplate.Evses != null && stationTemplate.Connectors == null) {
744 for (const evse in stationTemplate.Evses) {
745 if (evse === '0') {
746 continue
747 }
748 configuredMaxNumberOfConnectors += getMaxNumberOfConnectors(
749 stationTemplate.Evses[evse].Connectors
750 )
751 }
752 }
753 return configuredMaxNumberOfConnectors
754 }
755
756 const checkConfiguredMaxConnectors = (
757 configuredMaxConnectors: number,
758 logPrefix: string,
759 templateFile: string
760 ): void => {
761 if (configuredMaxConnectors <= 0) {
762 logger.warn(
763 `${logPrefix} Charging station information from template ${templateFile} with ${configuredMaxConnectors} connectors`
764 )
765 }
766 }
767
768 const checkTemplateMaxConnectors = (
769 templateMaxConnectors: number,
770 logPrefix: string,
771 templateFile: string
772 ): void => {
773 if (templateMaxConnectors === 0) {
774 logger.warn(
775 `${logPrefix} Charging station information from template ${templateFile} with empty connectors configuration`
776 )
777 } else if (templateMaxConnectors < 0) {
778 logger.error(
779 `${logPrefix} Charging station information from template ${templateFile} with no connectors configuration defined`
780 )
781 }
782 }
783
784 const initializeConnectorStatus = (connectorStatus: ConnectorStatus): void => {
785 connectorStatus.availability = AvailabilityType.Operative
786 connectorStatus.idTagLocalAuthorized = false
787 connectorStatus.idTagAuthorized = false
788 connectorStatus.transactionRemoteStarted = false
789 connectorStatus.transactionStarted = false
790 connectorStatus.energyActiveImportRegisterValue = 0
791 connectorStatus.transactionEnergyActiveImportRegisterValue = 0
792 if (connectorStatus.chargingProfiles == null) {
793 connectorStatus.chargingProfiles = []
794 }
795 }
796
797 const warnDeprecatedTemplateKey = (
798 template: ChargingStationTemplate,
799 key: string,
800 logPrefix: string,
801 templateFile: string,
802 logMsgToAppend = ''
803 ): void => {
804 if (template[key as keyof ChargingStationTemplate] != null) {
805 const logMsg = `Deprecated template key '${key}' usage in file '${templateFile}'${
806 isNotEmptyString(logMsgToAppend) ? `. ${logMsgToAppend}` : ''
807 }`
808 logger.warn(`${logPrefix} ${logMsg}`)
809 console.warn(`${chalk.green(logPrefix)} ${chalk.yellow(logMsg)}`)
810 }
811 }
812
813 const convertDeprecatedTemplateKey = (
814 template: ChargingStationTemplate,
815 deprecatedKey: string,
816 key?: string
817 ): void => {
818 if (template[deprecatedKey as keyof ChargingStationTemplate] != null) {
819 if (key != null) {
820 (template as unknown as Record<string, unknown>)[key] =
821 template[deprecatedKey as keyof ChargingStationTemplate]
822 }
823 // eslint-disable-next-line @typescript-eslint/no-dynamic-delete
824 delete template[deprecatedKey as keyof ChargingStationTemplate]
825 }
826 }
827
828 interface ChargingProfilesLimit {
829 limit: number
830 chargingProfile: ChargingProfile
831 }
832
833 /**
834 * Charging profiles shall already be sorted by connector id descending then stack level descending
835 *
836 * @param chargingStation -
837 * @param connectorId -
838 * @param chargingProfiles -
839 * @param logPrefix -
840 * @returns ChargingProfilesLimit
841 */
842 const getLimitFromChargingProfiles = (
843 chargingStation: ChargingStation,
844 connectorId: number,
845 chargingProfiles: ChargingProfile[],
846 logPrefix: string
847 ): ChargingProfilesLimit | undefined => {
848 const debugLogMsg = `${logPrefix} ${moduleName}.getLimitFromChargingProfiles: Matching charging profile found for power limitation: %j`
849 const currentDate = new Date()
850 const connectorStatus = chargingStation.getConnectorStatus(connectorId)
851 for (const chargingProfile of chargingProfiles) {
852 const chargingSchedule = chargingProfile.chargingSchedule
853 if (chargingSchedule.startSchedule == null) {
854 logger.debug(
855 `${logPrefix} ${moduleName}.getLimitFromChargingProfiles: Charging profile id ${chargingProfile.chargingProfileId} has no startSchedule defined. Trying to set it to the connector current transaction start date`
856 )
857 // OCPP specifies that if startSchedule is not defined, it should be relative to start of the connector transaction
858 chargingSchedule.startSchedule = connectorStatus?.transactionStart
859 }
860 if (!isDate(chargingSchedule.startSchedule)) {
861 logger.warn(
862 `${logPrefix} ${moduleName}.getLimitFromChargingProfiles: Charging profile id ${chargingProfile.chargingProfileId} startSchedule property is not a Date instance. Trying to convert it to a Date instance`
863 )
864 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
865 chargingSchedule.startSchedule = convertToDate(chargingSchedule.startSchedule)!
866 }
867 if (chargingSchedule.duration == null) {
868 logger.debug(
869 `${logPrefix} ${moduleName}.getLimitFromChargingProfiles: Charging profile id ${chargingProfile.chargingProfileId} has no duration defined and will be set to the maximum time allowed`
870 )
871 // OCPP specifies that if duration is not defined, it should be infinite
872 chargingSchedule.duration = differenceInSeconds(maxTime, chargingSchedule.startSchedule)
873 }
874 if (!prepareChargingProfileKind(connectorStatus, chargingProfile, currentDate, logPrefix)) {
875 continue
876 }
877 if (!canProceedChargingProfile(chargingProfile, currentDate, logPrefix)) {
878 continue
879 }
880 // Check if the charging profile is active
881 if (
882 isWithinInterval(currentDate, {
883 start: chargingSchedule.startSchedule,
884 end: addSeconds(chargingSchedule.startSchedule, chargingSchedule.duration)
885 })
886 ) {
887 if (isNotEmptyArray(chargingSchedule.chargingSchedulePeriod)) {
888 const chargingSchedulePeriodCompareFn = (
889 a: ChargingSchedulePeriod,
890 b: ChargingSchedulePeriod
891 ): number => a.startPeriod - b.startPeriod
892 if (
893 !isArraySorted<ChargingSchedulePeriod>(
894 chargingSchedule.chargingSchedulePeriod,
895 chargingSchedulePeriodCompareFn
896 )
897 ) {
898 logger.warn(
899 `${logPrefix} ${moduleName}.getLimitFromChargingProfiles: Charging profile id ${chargingProfile.chargingProfileId} schedule periods are not sorted by start period`
900 )
901 chargingSchedule.chargingSchedulePeriod.sort(chargingSchedulePeriodCompareFn)
902 }
903 // Check if the first schedule period startPeriod property is equal to 0
904 if (chargingSchedule.chargingSchedulePeriod[0].startPeriod !== 0) {
905 logger.error(
906 `${logPrefix} ${moduleName}.getLimitFromChargingProfiles: Charging profile id ${chargingProfile.chargingProfileId} first schedule period start period ${chargingSchedule.chargingSchedulePeriod[0].startPeriod} is not equal to 0`
907 )
908 continue
909 }
910 // Handle only one schedule period
911 if (chargingSchedule.chargingSchedulePeriod.length === 1) {
912 const result: ChargingProfilesLimit = {
913 limit: chargingSchedule.chargingSchedulePeriod[0].limit,
914 chargingProfile
915 }
916 logger.debug(debugLogMsg, result)
917 return result
918 }
919 let previousChargingSchedulePeriod: ChargingSchedulePeriod | undefined
920 // Search for the right schedule period
921 for (const [
922 index,
923 chargingSchedulePeriod
924 ] of chargingSchedule.chargingSchedulePeriod.entries()) {
925 // Find the right schedule period
926 if (
927 isAfter(
928 addSeconds(chargingSchedule.startSchedule, chargingSchedulePeriod.startPeriod),
929 currentDate
930 )
931 ) {
932 // Found the schedule period: previous is the correct one
933 const result: ChargingProfilesLimit = {
934 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
935 limit: previousChargingSchedulePeriod!.limit,
936 chargingProfile
937 }
938 logger.debug(debugLogMsg, result)
939 return result
940 }
941 // Keep a reference to previous one
942 previousChargingSchedulePeriod = chargingSchedulePeriod
943 // Handle the last schedule period within the charging profile duration
944 if (
945 index === chargingSchedule.chargingSchedulePeriod.length - 1 ||
946 (index < chargingSchedule.chargingSchedulePeriod.length - 1 &&
947 differenceInSeconds(
948 addSeconds(
949 chargingSchedule.startSchedule,
950 chargingSchedule.chargingSchedulePeriod[index + 1].startPeriod
951 ),
952 chargingSchedule.startSchedule
953 ) > chargingSchedule.duration)
954 ) {
955 const result: ChargingProfilesLimit = {
956 limit: previousChargingSchedulePeriod.limit,
957 chargingProfile
958 }
959 logger.debug(debugLogMsg, result)
960 return result
961 }
962 }
963 }
964 }
965 }
966 }
967
968 export const prepareChargingProfileKind = (
969 connectorStatus: ConnectorStatus | undefined,
970 chargingProfile: ChargingProfile,
971 currentDate: string | number | Date,
972 logPrefix: string
973 ): boolean => {
974 switch (chargingProfile.chargingProfileKind) {
975 case ChargingProfileKindType.RECURRING:
976 if (!canProceedRecurringChargingProfile(chargingProfile, logPrefix)) {
977 return false
978 }
979 prepareRecurringChargingProfile(chargingProfile, currentDate, logPrefix)
980 break
981 case ChargingProfileKindType.RELATIVE:
982 if (chargingProfile.chargingSchedule.startSchedule != null) {
983 logger.warn(
984 `${logPrefix} ${moduleName}.prepareChargingProfileKind: Relative charging profile id ${chargingProfile.chargingProfileId} has a startSchedule property defined. It will be ignored or used if the connector has a transaction started`
985 )
986 delete chargingProfile.chargingSchedule.startSchedule
987 }
988 if (connectorStatus?.transactionStarted === true) {
989 chargingProfile.chargingSchedule.startSchedule = connectorStatus.transactionStart
990 }
991 // FIXME: handle relative charging profile duration
992 break
993 }
994 return true
995 }
996
997 export const canProceedChargingProfile = (
998 chargingProfile: ChargingProfile,
999 currentDate: string | number | Date,
1000 logPrefix: string
1001 ): boolean => {
1002 if (
1003 (isValidDate(chargingProfile.validFrom) && isBefore(currentDate, chargingProfile.validFrom)) ||
1004 (isValidDate(chargingProfile.validTo) && isAfter(currentDate, chargingProfile.validTo))
1005 ) {
1006 logger.debug(
1007 `${logPrefix} ${moduleName}.canProceedChargingProfile: Charging profile id ${
1008 chargingProfile.chargingProfileId
1009 } is not valid for the current date ${
1010 isDate(currentDate) ? currentDate.toISOString() : currentDate
1011 }`
1012 )
1013 return false
1014 }
1015 if (
1016 chargingProfile.chargingSchedule.startSchedule == null ||
1017 chargingProfile.chargingSchedule.duration == null
1018 ) {
1019 logger.error(
1020 `${logPrefix} ${moduleName}.canProceedChargingProfile: Charging profile id ${chargingProfile.chargingProfileId} has no startSchedule or duration defined`
1021 )
1022 return false
1023 }
1024 if (!isValidDate(chargingProfile.chargingSchedule.startSchedule)) {
1025 logger.error(
1026 `${logPrefix} ${moduleName}.canProceedChargingProfile: Charging profile id ${chargingProfile.chargingProfileId} has an invalid startSchedule date defined`
1027 )
1028 return false
1029 }
1030 if (!Number.isSafeInteger(chargingProfile.chargingSchedule.duration)) {
1031 logger.error(
1032 `${logPrefix} ${moduleName}.canProceedChargingProfile: Charging profile id ${chargingProfile.chargingProfileId} has non integer duration defined`
1033 )
1034 return false
1035 }
1036 return true
1037 }
1038
1039 const canProceedRecurringChargingProfile = (
1040 chargingProfile: ChargingProfile,
1041 logPrefix: string
1042 ): boolean => {
1043 if (
1044 chargingProfile.chargingProfileKind === ChargingProfileKindType.RECURRING &&
1045 chargingProfile.recurrencyKind == null
1046 ) {
1047 logger.error(
1048 `${logPrefix} ${moduleName}.canProceedRecurringChargingProfile: Recurring charging profile id ${chargingProfile.chargingProfileId} has no recurrencyKind defined`
1049 )
1050 return false
1051 }
1052 if (
1053 chargingProfile.chargingProfileKind === ChargingProfileKindType.RECURRING &&
1054 chargingProfile.chargingSchedule.startSchedule == null
1055 ) {
1056 logger.error(
1057 `${logPrefix} ${moduleName}.canProceedRecurringChargingProfile: Recurring charging profile id ${chargingProfile.chargingProfileId} has no startSchedule defined`
1058 )
1059 return false
1060 }
1061 return true
1062 }
1063
1064 /**
1065 * Adjust recurring charging profile startSchedule to the current recurrency time interval if needed
1066 *
1067 * @param chargingProfile -
1068 * @param currentDate -
1069 * @param logPrefix -
1070 */
1071 const prepareRecurringChargingProfile = (
1072 chargingProfile: ChargingProfile,
1073 currentDate: string | number | Date,
1074 logPrefix: string
1075 ): boolean => {
1076 const chargingSchedule = chargingProfile.chargingSchedule
1077 let recurringIntervalTranslated = false
1078 let recurringInterval: Interval | undefined
1079 switch (chargingProfile.recurrencyKind) {
1080 case RecurrencyKindType.DAILY:
1081 recurringInterval = {
1082 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
1083 start: chargingSchedule.startSchedule!,
1084 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
1085 end: addDays(chargingSchedule.startSchedule!, 1)
1086 }
1087 checkRecurringChargingProfileDuration(chargingProfile, recurringInterval, logPrefix)
1088 if (
1089 !isWithinInterval(currentDate, recurringInterval) &&
1090 isBefore(recurringInterval.end, currentDate)
1091 ) {
1092 chargingSchedule.startSchedule = addDays(
1093 recurringInterval.start,
1094 differenceInDays(currentDate, recurringInterval.start)
1095 )
1096 recurringInterval = {
1097 start: chargingSchedule.startSchedule,
1098 end: addDays(chargingSchedule.startSchedule, 1)
1099 }
1100 recurringIntervalTranslated = true
1101 }
1102 break
1103 case RecurrencyKindType.WEEKLY:
1104 recurringInterval = {
1105 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
1106 start: chargingSchedule.startSchedule!,
1107 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
1108 end: addWeeks(chargingSchedule.startSchedule!, 1)
1109 }
1110 checkRecurringChargingProfileDuration(chargingProfile, recurringInterval, logPrefix)
1111 if (
1112 !isWithinInterval(currentDate, recurringInterval) &&
1113 isBefore(recurringInterval.end, currentDate)
1114 ) {
1115 chargingSchedule.startSchedule = addWeeks(
1116 recurringInterval.start,
1117 differenceInWeeks(currentDate, recurringInterval.start)
1118 )
1119 recurringInterval = {
1120 start: chargingSchedule.startSchedule,
1121 end: addWeeks(chargingSchedule.startSchedule, 1)
1122 }
1123 recurringIntervalTranslated = true
1124 }
1125 break
1126 default:
1127 logger.error(
1128 `${logPrefix} ${moduleName}.prepareRecurringChargingProfile: Recurring ${chargingProfile.recurrencyKind} charging profile id ${chargingProfile.chargingProfileId} is not supported`
1129 )
1130 }
1131 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
1132 if (recurringIntervalTranslated && !isWithinInterval(currentDate, recurringInterval!)) {
1133 logger.error(
1134 `${logPrefix} ${moduleName}.prepareRecurringChargingProfile: Recurring ${
1135 chargingProfile.recurrencyKind
1136 } charging profile id ${chargingProfile.chargingProfileId} recurrency time interval [${toDate(
1137 recurringInterval?.start as Date
1138 ).toISOString()}, ${toDate(
1139 recurringInterval?.end as Date
1140 ).toISOString()}] has not been properly translated to current date ${
1141 isDate(currentDate) ? currentDate.toISOString() : currentDate
1142 } `
1143 )
1144 }
1145 return recurringIntervalTranslated
1146 }
1147
1148 const checkRecurringChargingProfileDuration = (
1149 chargingProfile: ChargingProfile,
1150 interval: Interval,
1151 logPrefix: string
1152 ): void => {
1153 if (chargingProfile.chargingSchedule.duration == null) {
1154 logger.warn(
1155 `${logPrefix} ${moduleName}.checkRecurringChargingProfileDuration: Recurring ${
1156 chargingProfile.chargingProfileKind
1157 } charging profile id ${
1158 chargingProfile.chargingProfileId
1159 } duration is not defined, set it to the recurrency time interval duration ${differenceInSeconds(
1160 interval.end,
1161 interval.start
1162 )}`
1163 )
1164 chargingProfile.chargingSchedule.duration = differenceInSeconds(interval.end, interval.start)
1165 } else if (
1166 chargingProfile.chargingSchedule.duration > differenceInSeconds(interval.end, interval.start)
1167 ) {
1168 logger.warn(
1169 `${logPrefix} ${moduleName}.checkRecurringChargingProfileDuration: Recurring ${
1170 chargingProfile.chargingProfileKind
1171 } charging profile id ${chargingProfile.chargingProfileId} duration ${
1172 chargingProfile.chargingSchedule.duration
1173 } is greater than the recurrency time interval duration ${differenceInSeconds(
1174 interval.end,
1175 interval.start
1176 )}`
1177 )
1178 chargingProfile.chargingSchedule.duration = differenceInSeconds(interval.end, interval.start)
1179 }
1180 }
1181
1182 const getRandomSerialNumberSuffix = (params?: {
1183 randomBytesLength?: number
1184 upperCase?: boolean
1185 }): string => {
1186 const randomSerialNumberSuffix = randomBytes(params?.randomBytesLength ?? 16).toString('hex')
1187 if (params?.upperCase === true) {
1188 return randomSerialNumberSuffix.toUpperCase()
1189 }
1190 return randomSerialNumberSuffix
1191 }