feat: add template tunable to stop transaction at CS stop
[e-mobility-charging-stations-simulator.git] / src / charging-station / ChargingStation.ts
index 5aa4708b2fdb240890a9449d9fb6923e25d9e796..033152d6bf0d62c760f4477f3324351198c4f31b 100644 (file)
@@ -1,15 +1,8 @@
 // Partial Copyright Jerome Benoit. 2021-2023. All Rights Reserved.
 
 import { createHash } from 'node:crypto';
-import {
-  type FSWatcher,
-  closeSync,
-  existsSync,
-  mkdirSync,
-  openSync,
-  readFileSync,
-  writeFileSync,
-} from 'node:fs';
+import { EventEmitter } from 'node:events';
+import { type FSWatcher, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
 import { dirname, join } from 'node:path';
 import { URL } from 'node:url';
 import { parentPort } from 'node:worker_threads';
@@ -75,6 +68,7 @@ import {
   type BootNotificationResponse,
   type CachedRequest,
   type ChargingStationConfiguration,
+  ChargingStationEvents,
   type ChargingStationInfo,
   type ChargingStationOcppConfiguration,
   type ChargingStationTemplate,
@@ -152,13 +146,15 @@ import {
   isUndefined,
   logPrefix,
   logger,
+  min,
+  once,
   roundTo,
   secureRandom,
   sleep,
   watchJsonFile,
 } from '../utils';
 
-export class ChargingStation {
+export class ChargingStation extends EventEmitter {
   public readonly index: number;
   public readonly templateFile: string;
   public stationInfo!: ChargingStationInfo;
@@ -167,7 +163,7 @@ export class ChargingStation {
   public idTagsCache: IdTagsCache;
   public automaticTransactionGenerator!: AutomaticTransactionGenerator | undefined;
   public ocppConfiguration!: ChargingStationOcppConfiguration | undefined;
-  public wsConnection!: WebSocket | null;
+  public wsConnection: WebSocket | null;
   public readonly connectors: Map<number, ConnectorStatus>;
   public readonly evses: Map<number, EvseStatus>;
   public readonly requests: Map<string, CachedRequest>;
@@ -196,9 +192,11 @@ export class ChargingStation {
   private reservationExpirationSetInterval?: NodeJS.Timeout;
 
   constructor(index: number, templateFile: string) {
+    super();
     this.started = false;
     this.starting = false;
     this.stopping = false;
+    this.wsConnection = null;
     this.wsConnectionRestarted = false;
     this.autoReconnectRetryCount = 0;
     this.index = index;
@@ -211,6 +209,16 @@ export class ChargingStation {
     this.idTagsCache = IdTagsCache.getInstance();
     this.chargingStationWorkerBroadcastChannel = new ChargingStationWorkerBroadcastChannel(this);
 
+    this.on(ChargingStationEvents.started, () => {
+      parentPort?.postMessage(buildStartedMessage(this));
+    });
+    this.on(ChargingStationEvents.stopped, () => {
+      parentPort?.postMessage(buildStoppedMessage(this));
+    });
+    this.on(ChargingStationEvents.updated, () => {
+      parentPort?.postMessage(buildUpdatedMessage(this));
+    });
+
     this.initialize();
   }
 
@@ -387,7 +395,7 @@ export class ChargingStation {
     const connectorMaximumPower = this.getMaximumPower() / this.powerDivider;
     const connectorChargingProfilesPowerLimit =
       getChargingStationConnectorChargingProfilesPowerLimit(this, connectorId);
-    return Math.min(
+    return min(
       isNaN(connectorMaximumPower) ? Infinity : connectorMaximumPower,
       isNaN(connectorAmperageLimitationPowerLimit!)
         ? Infinity
@@ -689,7 +697,7 @@ export class ChargingStation {
                 // Restart the ATG
                 this.stopAutomaticTransactionGenerator();
                 delete this.automaticTransactionGeneratorConfiguration;
-                if (this.getAutomaticTransactionGeneratorConfiguration()?.enable === true) {
+                if (this.getAutomaticTransactionGeneratorConfiguration().enable === true) {
                   this.startAutomaticTransactionGenerator();
                 }
                 if (this.getEnableStatistics() === true) {
@@ -708,7 +716,7 @@ export class ChargingStation {
           },
         );
         this.started = true;
-        parentPort?.postMessage(buildStartedMessage(this));
+        this.emit(ChargingStationEvents.started);
         this.starting = false;
       } else {
         logger.warn(`${this.logPrefix()} Charging station is already starting...`);
@@ -718,11 +726,11 @@ export class ChargingStation {
     }
   }
 
-  public async stop(reason?: StopTransactionReason): Promise<void> {
+  public async stop(reason?: StopTransactionReason, stopTransactions?: boolean): Promise<void> {
     if (this.started === true) {
       if (this.stopping === false) {
         this.stopping = true;
-        await this.stopMessageSequence(reason);
+        await this.stopMessageSequence(reason, stopTransactions);
         this.closeWSConnection();
         if (this.getEnableStatistics() === true) {
           this.performanceStatistics?.stop();
@@ -736,7 +744,7 @@ export class ChargingStation {
         delete this.bootNotificationResponse;
         this.started = false;
         this.saveConfiguration();
-        parentPort?.postMessage(buildStoppedMessage(this));
+        this.emit(ChargingStationEvents.stopped);
         this.stopping = false;
       } else {
         logger.warn(`${this.logPrefix()} Charging station is already stopping...`);
@@ -841,17 +849,17 @@ export class ChargingStation {
       let automaticTransactionGeneratorConfiguration:
         | AutomaticTransactionGeneratorConfiguration
         | undefined;
-      const automaticTransactionGeneratorConfigurationFromFile =
-        this.getConfigurationFromFile()?.automaticTransactionGenerator;
+      const stationTemplate = this.getTemplateFromFile();
+      const stationConfiguration = this.getConfigurationFromFile();
       if (
         this.getAutomaticTransactionGeneratorPersistentConfiguration() &&
-        automaticTransactionGeneratorConfigurationFromFile
+        stationConfiguration?.stationInfo?.templateHash === stationTemplate?.templateHash &&
+        stationConfiguration?.automaticTransactionGenerator
       ) {
         automaticTransactionGeneratorConfiguration =
-          automaticTransactionGeneratorConfigurationFromFile;
+          stationConfiguration?.automaticTransactionGenerator;
       } else {
-        automaticTransactionGeneratorConfiguration =
-          this.getTemplateFromFile()?.AutomaticTransactionGenerator;
+        automaticTransactionGeneratorConfiguration = stationTemplate?.AutomaticTransactionGenerator;
       }
       this.automaticTransactionGeneratorConfiguration = {
         ...Constants.DEFAULT_ATG_CONFIGURATION,
@@ -875,7 +883,7 @@ export class ChargingStation {
       this.automaticTransactionGenerator?.start();
     }
     this.saveAutomaticTransactionGeneratorConfiguration();
-    parentPort?.postMessage(buildUpdatedMessage(this));
+    this.emit(ChargingStationEvents.updated);
   }
 
   public stopAutomaticTransactionGenerator(connectorIds?: number[]): void {
@@ -887,12 +895,12 @@ export class ChargingStation {
       this.automaticTransactionGenerator?.stop();
     }
     this.saveAutomaticTransactionGeneratorConfiguration();
-    parentPort?.postMessage(buildUpdatedMessage(this));
+    this.emit(ChargingStationEvents.updated);
   }
 
   public async stopTransactionOnConnector(
     connectorId: number,
-    reason = StopTransactionReason.NONE,
+    reason?: StopTransactionReason,
   ): Promise<StopTransactionResponse> {
     const transactionId = this.getConnectorStatus(connectorId)?.transactionId;
     if (
@@ -922,7 +930,7 @@ export class ChargingStation {
       {
         transactionId,
         meterStop: this.getEnergyActiveImportRegisterByTransactionId(transactionId!, true),
-        reason,
+        ...(isNullOrUndefined(reason) && { reason }),
       },
     );
   }
@@ -1025,8 +1033,7 @@ export class ChargingStation {
   }
 
   private startReservationExpirationSetInterval(customInterval?: number): void {
-    const interval =
-      customInterval ?? Constants.DEFAULT_RESERVATION_EXPIRATION_OBSERVATION_INTERVAL;
+    const interval = customInterval ?? Constants.DEFAULT_RESERVATION_EXPIRATION_INTERVAL;
     if (interval > 0) {
       logger.info(
         `${this.logPrefix()} Reservation expiration date checks started every ${formatDurationMilliSeconds(
@@ -1040,7 +1047,7 @@ export class ChargingStation {
   }
 
   private stopReservationExpirationSetInterval(): void {
-    if (this.reservationExpirationSetInterval) {
+    if (!isNullOrUndefined(this.reservationExpirationSetInterval)) {
       clearInterval(this.reservationExpirationSetInterval);
     }
   }
@@ -1134,7 +1141,8 @@ export class ChargingStation {
   private getStationInfoFromTemplate(): ChargingStationInfo {
     const stationTemplate: ChargingStationTemplate = this.getTemplateFromFile()!;
     checkTemplate(stationTemplate, this.logPrefix(), this.templateFile);
-    warnTemplateKeysDeprecation(stationTemplate, this.logPrefix(), this.templateFile);
+    const warnTemplateKeysDeprecationOnce = once(warnTemplateKeysDeprecation, this);
+    warnTemplateKeysDeprecationOnce(stationTemplate, this.logPrefix(), this.templateFile);
     if (stationTemplate?.Connectors) {
       checkConnectorsConfiguration(stationTemplate, this.logPrefix(), this.templateFile);
     }
@@ -1245,13 +1253,13 @@ export class ChargingStation {
       dirname(this.templateFile.replace('station-templates', 'configurations')),
       `${getHashId(this.index, stationTemplate)}.json`,
     );
-    const chargingStationConfiguration = this.getConfigurationFromFile();
+    const stationConfiguration = this.getConfigurationFromFile();
     if (
-      chargingStationConfiguration?.stationInfo?.templateHash === stationTemplate?.templateHash &&
+      stationConfiguration?.stationInfo?.templateHash === stationTemplate?.templateHash &&
       // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
-      (chargingStationConfiguration?.connectorsStatus || chargingStationConfiguration?.evsesStatus)
+      (stationConfiguration?.connectorsStatus || stationConfiguration?.evsesStatus)
     ) {
-      this.initializeConnectorsOrEvsesFromFile(chargingStationConfiguration);
+      this.initializeConnectorsOrEvsesFromFile(stationConfiguration);
     } else {
       this.initializeConnectorsOrEvsesFromTemplate(stationTemplate);
     }
@@ -1695,34 +1703,35 @@ export class ChargingStation {
               stationInfo: configurationData.stationInfo,
               configurationKey: configurationData.configurationKey,
               automaticTransactionGenerator: configurationData.automaticTransactionGenerator,
+              ...(this.connectors.size > 0 && {
+                connectorsStatus: configurationData.connectorsStatus,
+              }),
+              ...(this.evses.size > 0 && { evsesStatus: configurationData.evsesStatus }),
             } as ChargingStationConfiguration),
           )
           .digest('hex');
         if (this.configurationFileHash !== configurationHash) {
-          AsyncLock.acquire(AsyncLockType.configuration)
-            .then(() => {
-              configurationData.configurationHash = configurationHash;
-              const measureId = `${FileType.ChargingStationConfiguration} write`;
-              const beginId = PerformanceStatistics.beginMeasure(measureId);
-              const fileDescriptor = openSync(this.configurationFile, 'w');
-              writeFileSync(fileDescriptor, JSON.stringify(configurationData, null, 2), 'utf8');
-              closeSync(fileDescriptor);
-              PerformanceStatistics.endMeasure(measureId, beginId);
-              this.sharedLRUCache.deleteChargingStationConfiguration(this.configurationFileHash);
-              this.sharedLRUCache.setChargingStationConfiguration(configurationData);
-              this.configurationFileHash = configurationHash;
-            })
-            .catch((error) => {
-              handleFileException(
-                this.configurationFile,
-                FileType.ChargingStationConfiguration,
-                error as NodeJS.ErrnoException,
-                this.logPrefix(),
-              );
-            })
-            .finally(() => {
-              AsyncLock.release(AsyncLockType.configuration).catch(Constants.EMPTY_FUNCTION);
-            });
+          AsyncLock.runExclusive(AsyncLockType.configuration, () => {
+            configurationData.configurationHash = configurationHash;
+            const measureId = `${FileType.ChargingStationConfiguration} write`;
+            const beginId = PerformanceStatistics.beginMeasure(measureId);
+            writeFileSync(
+              this.configurationFile,
+              JSON.stringify(configurationData, undefined, 2),
+              'utf8',
+            );
+            PerformanceStatistics.endMeasure(measureId, beginId);
+            this.sharedLRUCache.deleteChargingStationConfiguration(this.configurationFileHash);
+            this.sharedLRUCache.setChargingStationConfiguration(configurationData);
+            this.configurationFileHash = configurationHash;
+          }).catch((error) => {
+            handleFileException(
+              this.configurationFile,
+              FileType.ChargingStationConfiguration,
+              error as NodeJS.ErrnoException,
+              this.logPrefix(),
+            );
+          });
         } else {
           logger.debug(
             `${this.logPrefix()} Not saving unchanged charging station configuration file ${
@@ -1796,7 +1805,9 @@ export class ChargingStation {
         );
       }
       if (this.isRegistered() === true) {
+        this.emit(ChargingStationEvents.registered);
         if (this.inAcceptedState() === true) {
+          this.emit(ChargingStationEvents.accepted);
           await this.startMessageSequence();
         }
       } else {
@@ -1806,7 +1817,7 @@ export class ChargingStation {
       }
       this.wsConnectionRestarted = false;
       this.autoReconnectRetryCount = 0;
-      parentPort?.postMessage(buildUpdatedMessage(this));
+      this.emit(ChargingStationEvents.updated);
     } else {
       logger.warn(
         `${this.logPrefix()} Connection to OCPP server through ${this.wsConnectionUrl.toString()} failed`,
@@ -1836,7 +1847,7 @@ export class ChargingStation {
         this.started === true && (await this.reconnect());
         break;
     }
-    parentPort?.postMessage(buildUpdatedMessage(this));
+    this.emit(ChargingStationEvents.updated);
   }
 
   private getCachedRequest(messageType: MessageType, messageId: string): CachedRequest | undefined {
@@ -1947,7 +1958,7 @@ export class ChargingStation {
             logger.error(`${this.logPrefix()} ${errorMsg}`);
             throw new OCPPError(ErrorType.PROTOCOL_ERROR, errorMsg);
         }
-        parentPort?.postMessage(buildUpdatedMessage(this));
+        this.emit(ChargingStationEvents.updated);
       } else {
         throw new OCPPError(
           ErrorType.PROTOCOL_ERROR,
@@ -2036,7 +2047,7 @@ export class ChargingStation {
     return stationTemplate?.useConnectorId0 ?? true;
   }
 
-  private async stopRunningTransactions(reason = StopTransactionReason.NONE): Promise<void> {
+  private async stopRunningTransactions(reason?: StopTransactionReason): Promise<void> {
     if (this.hasEvses) {
       for (const [evseId, evseStatus] of this.evses) {
         if (evseId === 0) {
@@ -2070,12 +2081,10 @@ export class ChargingStation {
 
   // -1 for unlimited, 0 for disabling
   private getAutoReconnectMaxRetries(): number | undefined {
-    return (
-      this.stationInfo.autoReconnectMaxRetries ?? Configuration.getAutoReconnectMaxRetries() ?? -1
-    );
+    return this.stationInfo.autoReconnectMaxRetries ?? -1;
   }
 
-  // 0 for disabling
+  // -1 for unlimited, 0 for disabling
   private getRegistrationMaxRetries(): number | undefined {
     return this.stationInfo.registrationMaxRetries ?? -1;
   }
@@ -2166,24 +2175,25 @@ export class ChargingStation {
     }
 
     // Start the ATG
-    if (this.getAutomaticTransactionGeneratorConfiguration()?.enable === true) {
+    if (this.getAutomaticTransactionGeneratorConfiguration().enable === true) {
       this.startAutomaticTransactionGenerator();
     }
     this.wsConnectionRestarted === true && this.flushMessageBuffer();
   }
 
   private async stopMessageSequence(
-    reason: StopTransactionReason = StopTransactionReason.NONE,
+    reason?: StopTransactionReason,
+    stopTransactions = this.stationInfo?.stopTransactionsOnStopped ?? true,
   ): Promise<void> {
     // Stop WebSocket ping
     this.stopWebSocketPing();
     // Stop heartbeat
     this.stopHeartbeat();
     // Stop ongoing transactions
+    stopTransactions && (await this.stopRunningTransactions(reason));
+    // Stop the ATG
     if (this.automaticTransactionGenerator?.started === true) {
       this.stopAutomaticTransactionGenerator();
-    } else {
-      await this.stopRunningTransactions(reason);
     }
     if (this.hasEvses) {
       for (const [evseId, evseStatus] of this.evses) {