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