e2e9821e28c915804c7a9aec4bac79468023e956
[e-mobility-charging-stations-simulator.git] / src / charging-station / ChargingStationUtils.ts
1 import crypto from 'node:crypto';
2 import path from 'node:path';
3 import { fileURLToPath } from 'node:url';
4
5 import chalk from 'chalk';
6 import moment from 'moment';
7
8 import type { ChargingStation } from './internal';
9 import { BaseError } from '../exception';
10 import {
11 AmpereUnits,
12 type BootNotificationRequest,
13 BootReasonEnumType,
14 type ChargingProfile,
15 ChargingProfileKindType,
16 ChargingRateUnitType,
17 type ChargingSchedulePeriod,
18 type ChargingStationInfo,
19 type ChargingStationTemplate,
20 type ConnectorStatus,
21 CurrentType,
22 type OCPP16BootNotificationRequest,
23 type OCPP20BootNotificationRequest,
24 OCPPVersion,
25 RecurrencyKindType,
26 Voltage,
27 } from '../types';
28 import {
29 ACElectricUtils,
30 Configuration,
31 Constants,
32 DCElectricUtils,
33 Utils,
34 logger,
35 } from '../utils';
36 import { WorkerProcessType } from '../worker';
37
38 const moduleName = 'ChargingStationUtils';
39
40 export class ChargingStationUtils {
41 private constructor() {
42 // This is intentional
43 }
44
45 public static getChargingStationId(
46 index: number,
47 stationTemplate: ChargingStationTemplate
48 ): string {
49 // In case of multiple instances: add instance index to charging station id
50 const instanceIndex = process.env.CF_INSTANCE_INDEX ?? 0;
51 const idSuffix = stationTemplate?.nameSuffix ?? '';
52 const idStr = `000000000${index.toString()}`;
53 return stationTemplate?.fixedName
54 ? stationTemplate.baseName
55 : `${stationTemplate.baseName}-${instanceIndex.toString()}${idStr.substring(
56 idStr.length - 4
57 )}${idSuffix}`;
58 }
59
60 public static getHashId(index: number, stationTemplate: ChargingStationTemplate): string {
61 const chargingStationInfo = {
62 chargePointModel: stationTemplate.chargePointModel,
63 chargePointVendor: stationTemplate.chargePointVendor,
64 ...(!Utils.isUndefined(stationTemplate.chargeBoxSerialNumberPrefix) && {
65 chargeBoxSerialNumber: stationTemplate.chargeBoxSerialNumberPrefix,
66 }),
67 ...(!Utils.isUndefined(stationTemplate.chargePointSerialNumberPrefix) && {
68 chargePointSerialNumber: stationTemplate.chargePointSerialNumberPrefix,
69 }),
70 ...(!Utils.isUndefined(stationTemplate.meterSerialNumberPrefix) && {
71 meterSerialNumber: stationTemplate.meterSerialNumberPrefix,
72 }),
73 ...(!Utils.isUndefined(stationTemplate.meterType) && {
74 meterType: stationTemplate.meterType,
75 }),
76 };
77 return crypto
78 .createHash(Constants.DEFAULT_HASH_ALGORITHM)
79 .update(
80 `${JSON.stringify(chargingStationInfo)}${ChargingStationUtils.getChargingStationId(
81 index,
82 stationTemplate
83 )}`
84 )
85 .digest('hex');
86 }
87
88 public static checkChargingStation(chargingStation: ChargingStation, logPrefix: string): boolean {
89 if (chargingStation.started === false && chargingStation.starting === false) {
90 logger.warn(`${logPrefix} charging station is stopped, cannot proceed`);
91 return false;
92 }
93 return true;
94 }
95
96 public static getMaxNumberOfConnectors(connectors: Record<string, ConnectorStatus>): number {
97 if (!connectors) {
98 return -1;
99 }
100 return Object.keys(connectors).length;
101 }
102
103 public static checkTemplateMaxConnectors(
104 templateMaxConnectors: number,
105 templateFile: string,
106 logPrefix: string
107 ): void {
108 if (templateMaxConnectors === 0) {
109 logger.warn(
110 `${logPrefix} Charging station information from template ${templateFile} with empty connectors configuration`
111 );
112 } else if (templateMaxConnectors < 0) {
113 logger.error(
114 `${logPrefix} Charging station information from template ${templateFile} with no connectors configuration defined`
115 );
116 }
117 }
118
119 public static getConfiguredNumberOfConnectors(stationTemplate: ChargingStationTemplate): number {
120 let configuredMaxConnectors: number;
121 if (Utils.isNotEmptyArray(stationTemplate.numberOfConnectors) === true) {
122 const numberOfConnectors = stationTemplate.numberOfConnectors as number[];
123 configuredMaxConnectors =
124 numberOfConnectors[Math.floor(Utils.secureRandom() * numberOfConnectors.length)];
125 } else if (Utils.isUndefined(stationTemplate.numberOfConnectors) === false) {
126 configuredMaxConnectors = stationTemplate.numberOfConnectors as number;
127 } else if (stationTemplate.Connectors && !stationTemplate.Evses) {
128 configuredMaxConnectors = stationTemplate?.Connectors[0]
129 ? ChargingStationUtils.getMaxNumberOfConnectors(stationTemplate.Connectors) - 1
130 : ChargingStationUtils.getMaxNumberOfConnectors(stationTemplate.Connectors);
131 } else if (stationTemplate.Evses && !stationTemplate.Connectors) {
132 configuredMaxConnectors = 0;
133 for (const evse in stationTemplate.Evses) {
134 if (evse === '0') {
135 continue;
136 }
137 configuredMaxConnectors += ChargingStationUtils.getMaxNumberOfConnectors(
138 stationTemplate.Evses[evse].Connectors
139 );
140 }
141 }
142 return configuredMaxConnectors;
143 }
144
145 public static checkConfiguredMaxConnectors(
146 configuredMaxConnectors: number,
147 templateFile: string,
148 logPrefix: string
149 ): void {
150 if (configuredMaxConnectors <= 0) {
151 logger.warn(
152 `${logPrefix} Charging station information from template ${templateFile} with ${configuredMaxConnectors} connectors`
153 );
154 }
155 }
156
157 public static createBootNotificationRequest(
158 stationInfo: ChargingStationInfo,
159 bootReason: BootReasonEnumType = BootReasonEnumType.PowerUp
160 ): BootNotificationRequest {
161 const ocppVersion = stationInfo.ocppVersion ?? OCPPVersion.VERSION_16;
162 switch (ocppVersion) {
163 case OCPPVersion.VERSION_16:
164 return {
165 chargePointModel: stationInfo.chargePointModel,
166 chargePointVendor: stationInfo.chargePointVendor,
167 ...(!Utils.isUndefined(stationInfo.chargeBoxSerialNumber) && {
168 chargeBoxSerialNumber: stationInfo.chargeBoxSerialNumber,
169 }),
170 ...(!Utils.isUndefined(stationInfo.chargePointSerialNumber) && {
171 chargePointSerialNumber: stationInfo.chargePointSerialNumber,
172 }),
173 ...(!Utils.isUndefined(stationInfo.firmwareVersion) && {
174 firmwareVersion: stationInfo.firmwareVersion,
175 }),
176 ...(!Utils.isUndefined(stationInfo.iccid) && { iccid: stationInfo.iccid }),
177 ...(!Utils.isUndefined(stationInfo.imsi) && { imsi: stationInfo.imsi }),
178 ...(!Utils.isUndefined(stationInfo.meterSerialNumber) && {
179 meterSerialNumber: stationInfo.meterSerialNumber,
180 }),
181 ...(!Utils.isUndefined(stationInfo.meterType) && {
182 meterType: stationInfo.meterType,
183 }),
184 } as OCPP16BootNotificationRequest;
185 case OCPPVersion.VERSION_20:
186 case OCPPVersion.VERSION_201:
187 return {
188 reason: bootReason,
189 chargingStation: {
190 model: stationInfo.chargePointModel,
191 vendorName: stationInfo.chargePointVendor,
192 ...(!Utils.isUndefined(stationInfo.firmwareVersion) && {
193 firmwareVersion: stationInfo.firmwareVersion,
194 }),
195 ...(!Utils.isUndefined(stationInfo.chargeBoxSerialNumber) && {
196 serialNumber: stationInfo.chargeBoxSerialNumber,
197 }),
198 ...((!Utils.isUndefined(stationInfo.iccid) || !Utils.isUndefined(stationInfo.imsi)) && {
199 modem: {
200 ...(!Utils.isUndefined(stationInfo.iccid) && { iccid: stationInfo.iccid }),
201 ...(!Utils.isUndefined(stationInfo.imsi) && { imsi: stationInfo.imsi }),
202 },
203 }),
204 },
205 } as OCPP20BootNotificationRequest;
206 }
207 }
208
209 public static workerPoolInUse(): boolean {
210 return [WorkerProcessType.dynamicPool, WorkerProcessType.staticPool].includes(
211 Configuration.getWorker().processType
212 );
213 }
214
215 public static workerDynamicPoolInUse(): boolean {
216 return Configuration.getWorker().processType === WorkerProcessType.dynamicPool;
217 }
218
219 public static warnTemplateKeysDeprecation(
220 templateFile: string,
221 stationTemplate: ChargingStationTemplate,
222 logPrefix: string
223 ) {
224 const templateKeys: { key: string; deprecatedKey: string }[] = [
225 { key: 'supervisionUrls', deprecatedKey: 'supervisionUrl' },
226 { key: 'idTagsFile', deprecatedKey: 'authorizationFile' },
227 ];
228 for (const templateKey of templateKeys) {
229 ChargingStationUtils.warnDeprecatedTemplateKey(
230 stationTemplate,
231 templateKey.deprecatedKey,
232 templateFile,
233 logPrefix,
234 `Use '${templateKey.key}' instead`
235 );
236 ChargingStationUtils.convertDeprecatedTemplateKey(
237 stationTemplate,
238 templateKey.deprecatedKey,
239 templateKey.key
240 );
241 }
242 }
243
244 public static stationTemplateToStationInfo(
245 stationTemplate: ChargingStationTemplate
246 ): ChargingStationInfo {
247 stationTemplate = Utils.cloneObject(stationTemplate);
248 delete stationTemplate.power;
249 delete stationTemplate.powerUnit;
250 delete stationTemplate.Configuration;
251 delete stationTemplate.AutomaticTransactionGenerator;
252 delete stationTemplate.chargeBoxSerialNumberPrefix;
253 delete stationTemplate.chargePointSerialNumberPrefix;
254 delete stationTemplate.meterSerialNumberPrefix;
255 return stationTemplate as unknown as ChargingStationInfo;
256 }
257
258 public static createStationInfoHash(stationInfo: ChargingStationInfo): void {
259 delete stationInfo.infoHash;
260 stationInfo.infoHash = crypto
261 .createHash(Constants.DEFAULT_HASH_ALGORITHM)
262 .update(JSON.stringify(stationInfo))
263 .digest('hex');
264 }
265
266 public static createSerialNumber(
267 stationTemplate: ChargingStationTemplate,
268 stationInfo: ChargingStationInfo,
269 params: {
270 randomSerialNumberUpperCase?: boolean;
271 randomSerialNumber?: boolean;
272 } = {
273 randomSerialNumberUpperCase: true,
274 randomSerialNumber: true,
275 }
276 ): void {
277 params = params ?? {};
278 params.randomSerialNumberUpperCase = params?.randomSerialNumberUpperCase ?? true;
279 params.randomSerialNumber = params?.randomSerialNumber ?? true;
280 const serialNumberSuffix = params?.randomSerialNumber
281 ? ChargingStationUtils.getRandomSerialNumberSuffix({
282 upperCase: params.randomSerialNumberUpperCase,
283 })
284 : '';
285 stationInfo.chargePointSerialNumber = Utils.isNotEmptyString(
286 stationTemplate?.chargePointSerialNumberPrefix
287 )
288 ? `${stationTemplate.chargePointSerialNumberPrefix}${serialNumberSuffix}`
289 : undefined;
290 stationInfo.chargeBoxSerialNumber = Utils.isNotEmptyString(
291 stationTemplate?.chargeBoxSerialNumberPrefix
292 )
293 ? `${stationTemplate.chargeBoxSerialNumberPrefix}${serialNumberSuffix}`
294 : undefined;
295 stationInfo.meterSerialNumber = Utils.isNotEmptyString(stationTemplate?.meterSerialNumberPrefix)
296 ? `${stationTemplate.meterSerialNumberPrefix}${serialNumberSuffix}`
297 : undefined;
298 }
299
300 public static propagateSerialNumber(
301 stationTemplate: ChargingStationTemplate,
302 stationInfoSrc: ChargingStationInfo,
303 stationInfoDst: ChargingStationInfo
304 ) {
305 if (!stationInfoSrc || !stationTemplate) {
306 throw new BaseError(
307 'Missing charging station template or existing configuration to propagate serial number'
308 );
309 }
310 stationTemplate?.chargePointSerialNumberPrefix && stationInfoSrc?.chargePointSerialNumber
311 ? (stationInfoDst.chargePointSerialNumber = stationInfoSrc.chargePointSerialNumber)
312 : stationInfoDst?.chargePointSerialNumber && delete stationInfoDst.chargePointSerialNumber;
313 stationTemplate?.chargeBoxSerialNumberPrefix && stationInfoSrc?.chargeBoxSerialNumber
314 ? (stationInfoDst.chargeBoxSerialNumber = stationInfoSrc.chargeBoxSerialNumber)
315 : stationInfoDst?.chargeBoxSerialNumber && delete stationInfoDst.chargeBoxSerialNumber;
316 stationTemplate?.meterSerialNumberPrefix && stationInfoSrc?.meterSerialNumber
317 ? (stationInfoDst.meterSerialNumber = stationInfoSrc.meterSerialNumber)
318 : stationInfoDst?.meterSerialNumber && delete stationInfoDst.meterSerialNumber;
319 }
320
321 public static getAmperageLimitationUnitDivider(stationInfo: ChargingStationInfo): number {
322 let unitDivider = 1;
323 switch (stationInfo.amperageLimitationUnit) {
324 case AmpereUnits.DECI_AMPERE:
325 unitDivider = 10;
326 break;
327 case AmpereUnits.CENTI_AMPERE:
328 unitDivider = 100;
329 break;
330 case AmpereUnits.MILLI_AMPERE:
331 unitDivider = 1000;
332 break;
333 }
334 return unitDivider;
335 }
336
337 public static getChargingStationConnectorChargingProfilesPowerLimit(
338 chargingStation: ChargingStation,
339 connectorId: number
340 ): number | undefined {
341 let limit: number, matchingChargingProfile: ChargingProfile;
342 // Get charging profiles for connector and sort by stack level
343 const chargingProfiles =
344 Utils.cloneObject(chargingStation.getConnectorStatus(connectorId)?.chargingProfiles)?.sort(
345 (a, b) => b.stackLevel - a.stackLevel
346 ) ?? [];
347 // Get profiles on connector 0
348 if (chargingStation.getConnectorStatus(0)?.chargingProfiles) {
349 chargingProfiles.push(
350 ...Utils.cloneObject(chargingStation.getConnectorStatus(0).chargingProfiles).sort(
351 (a, b) => b.stackLevel - a.stackLevel
352 )
353 );
354 }
355 if (Utils.isNotEmptyArray(chargingProfiles)) {
356 const result = ChargingStationUtils.getLimitFromChargingProfiles(
357 chargingProfiles,
358 chargingStation.logPrefix()
359 );
360 if (!Utils.isNullOrUndefined(result)) {
361 limit = result?.limit;
362 matchingChargingProfile = result?.matchingChargingProfile;
363 switch (chargingStation.getCurrentOutType()) {
364 case CurrentType.AC:
365 limit =
366 matchingChargingProfile.chargingSchedule.chargingRateUnit ===
367 ChargingRateUnitType.WATT
368 ? limit
369 : ACElectricUtils.powerTotal(
370 chargingStation.getNumberOfPhases(),
371 chargingStation.getVoltageOut(),
372 limit
373 );
374 break;
375 case CurrentType.DC:
376 limit =
377 matchingChargingProfile.chargingSchedule.chargingRateUnit ===
378 ChargingRateUnitType.WATT
379 ? limit
380 : DCElectricUtils.power(chargingStation.getVoltageOut(), limit);
381 }
382 const connectorMaximumPower =
383 chargingStation.getMaximumPower() / chargingStation.powerDivider;
384 if (limit > connectorMaximumPower) {
385 logger.error(
386 `${chargingStation.logPrefix()} Charging profile id ${
387 matchingChargingProfile.chargingProfileId
388 } limit ${limit} is greater than connector id ${connectorId} maximum ${connectorMaximumPower}: %j`,
389 result
390 );
391 limit = connectorMaximumPower;
392 }
393 }
394 }
395 return limit;
396 }
397
398 public static getDefaultVoltageOut(
399 currentType: CurrentType,
400 templateFile: string,
401 logPrefix: string
402 ): Voltage {
403 const errMsg = `Unknown ${currentType} currentOutType in template file ${templateFile}, cannot define default voltage out`;
404 let defaultVoltageOut: number;
405 switch (currentType) {
406 case CurrentType.AC:
407 defaultVoltageOut = Voltage.VOLTAGE_230;
408 break;
409 case CurrentType.DC:
410 defaultVoltageOut = Voltage.VOLTAGE_400;
411 break;
412 default:
413 logger.error(`${logPrefix} ${errMsg}`);
414 throw new BaseError(errMsg);
415 }
416 return defaultVoltageOut;
417 }
418
419 public static getIdTagsFile(stationInfo: ChargingStationInfo): string | undefined {
420 return (
421 stationInfo.idTagsFile &&
422 path.join(
423 path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../'),
424 'assets',
425 path.basename(stationInfo.idTagsFile)
426 )
427 );
428 }
429
430 private static warnDeprecatedTemplateKey(
431 template: ChargingStationTemplate,
432 key: string,
433 templateFile: string,
434 logPrefix: string,
435 logMsgToAppend = ''
436 ): void {
437 if (!Utils.isUndefined(template[key])) {
438 const logMsg = `Deprecated template key '${key}' usage in file '${templateFile}'${
439 Utils.isNotEmptyString(logMsgToAppend) ? `. ${logMsgToAppend}` : ''
440 }`;
441 logger.warn(`${logPrefix} ${logMsg}`);
442 console.warn(chalk.yellow(`${logMsg}`));
443 }
444 }
445
446 private static convertDeprecatedTemplateKey(
447 template: ChargingStationTemplate,
448 deprecatedKey: string,
449 key: string
450 ): void {
451 if (!Utils.isUndefined(template[deprecatedKey])) {
452 template[key] = template[deprecatedKey] as unknown;
453 delete template[deprecatedKey];
454 }
455 }
456
457 /**
458 * Charging profiles should already be sorted by connectorId and stack level (highest stack level has priority)
459 *
460 * @param chargingProfiles -
461 * @param logPrefix -
462 * @returns
463 */
464 private static getLimitFromChargingProfiles(
465 chargingProfiles: ChargingProfile[],
466 logPrefix: string
467 ): {
468 limit: number;
469 matchingChargingProfile: ChargingProfile;
470 } | null {
471 const debugLogMsg = `${logPrefix} ${moduleName}.getLimitFromChargingProfiles: Matching charging profile found for power limitation: %j`;
472 const currentMoment = moment();
473 const currentDate = new Date();
474 for (const chargingProfile of chargingProfiles) {
475 // Set helpers
476 const chargingSchedule = chargingProfile.chargingSchedule;
477 if (!chargingSchedule?.startSchedule) {
478 logger.warn(
479 `${logPrefix} ${moduleName}.getLimitFromChargingProfiles: startSchedule is not defined in charging profile id ${chargingProfile.chargingProfileId}`
480 );
481 }
482 // Check type (recurring) and if it is already active
483 // Adjust the daily recurring schedule to today
484 if (
485 chargingProfile.chargingProfileKind === ChargingProfileKindType.RECURRING &&
486 chargingProfile.recurrencyKind === RecurrencyKindType.DAILY &&
487 currentMoment.isAfter(chargingSchedule.startSchedule)
488 ) {
489 if (!(chargingSchedule?.startSchedule instanceof Date)) {
490 logger.warn(
491 `${logPrefix} ${moduleName}.getLimitFromChargingProfiles: startSchedule is not a Date object in charging profile id ${chargingProfile.chargingProfileId}. Trying to convert it to a Date object`
492 );
493 chargingSchedule.startSchedule = new Date(chargingSchedule.startSchedule);
494 }
495 chargingSchedule.startSchedule.setFullYear(
496 currentDate.getFullYear(),
497 currentDate.getMonth(),
498 currentDate.getDate()
499 );
500 // Check if the start of the schedule is yesterday
501 if (moment(chargingSchedule.startSchedule).isAfter(currentMoment)) {
502 chargingSchedule.startSchedule.setDate(currentDate.getDate() - 1);
503 }
504 } else if (moment(chargingSchedule.startSchedule).isAfter(currentMoment)) {
505 return null;
506 }
507 // Check if the charging profile is active
508 if (
509 moment(chargingSchedule.startSchedule)
510 .add(chargingSchedule.duration, 's')
511 .isAfter(currentMoment)
512 ) {
513 let lastButOneSchedule: ChargingSchedulePeriod;
514 // Search the right schedule period
515 for (const schedulePeriod of chargingSchedule.chargingSchedulePeriod) {
516 // Handling of only one period
517 if (
518 chargingSchedule.chargingSchedulePeriod.length === 1 &&
519 schedulePeriod.startPeriod === 0
520 ) {
521 const result = {
522 limit: schedulePeriod.limit,
523 matchingChargingProfile: chargingProfile,
524 };
525 logger.debug(debugLogMsg, result);
526 return result;
527 }
528 // Find the right schedule period
529 if (
530 moment(chargingSchedule.startSchedule)
531 .add(schedulePeriod.startPeriod, 's')
532 .isAfter(currentMoment)
533 ) {
534 // Found the schedule: last but one is the correct one
535 const result = {
536 limit: lastButOneSchedule.limit,
537 matchingChargingProfile: chargingProfile,
538 };
539 logger.debug(debugLogMsg, result);
540 return result;
541 }
542 // Keep it
543 lastButOneSchedule = schedulePeriod;
544 // Handle the last schedule period
545 if (
546 schedulePeriod.startPeriod ===
547 chargingSchedule.chargingSchedulePeriod[
548 chargingSchedule.chargingSchedulePeriod.length - 1
549 ].startPeriod
550 ) {
551 const result = {
552 limit: lastButOneSchedule.limit,
553 matchingChargingProfile: chargingProfile,
554 };
555 logger.debug(debugLogMsg, result);
556 return result;
557 }
558 }
559 }
560 }
561 return null;
562 }
563
564 private static getRandomSerialNumberSuffix(params?: {
565 randomBytesLength?: number;
566 upperCase?: boolean;
567 }): string {
568 const randomSerialNumberSuffix = crypto
569 .randomBytes(params?.randomBytesLength ?? 16)
570 .toString('hex');
571 if (params?.upperCase) {
572 return randomSerialNumberSuffix.toUpperCase();
573 }
574 return randomSerialNumberSuffix;
575 }
576 }