Ensure 1:1 mapping between charging station instance and its OCPP services
[e-mobility-charging-stations-simulator.git] / src / charging-station / ChargingStation.ts
index 8b5b1ac2a2120616250873f203d5d2e1e1e458b2..028de5918c9f38cc7d9dd4281b589611d19b19a4 100644 (file)
@@ -5,7 +5,6 @@ import { BootNotificationResponse, RegistrationStatus } from '../types/ocpp/Resp
 import ChargingStationConfiguration, { ConfigurationKey } from '../types/ChargingStationConfiguration';
 import ChargingStationTemplate, { CurrentType, PowerUnits, Voltage } from '../types/ChargingStationTemplate';
 import { ConnectorPhaseRotation, StandardParametersKey, SupportedFeatureProfiles, VendorDefaultParametersKey } from '../types/ocpp/Configuration';
-import { ConnectorStatus, SampledValueTemplate } from '../types/Connectors';
 import { MeterValueMeasurand, MeterValuePhase } from '../types/ocpp/MeterValues';
 import { WSError, WebSocketCloseEventStatusCode } from '../types/WebSocket';
 import WebSocket, { ClientOptions, Data, OPEN } from 'ws';
@@ -17,19 +16,23 @@ import ChargingStationInfo from '../types/ChargingStationInfo';
 import { ChargingStationWorkerMessageEvents } from '../types/ChargingStationWorker';
 import { ClientRequestArgs } from 'http';
 import Configuration from '../utils/Configuration';
+import { ConnectorStatus } from '../types/ConnectorStatus';
 import Constants from '../utils/Constants';
 import { ErrorType } from '../types/ocpp/ErrorType';
 import FileUtils from '../utils/FileUtils';
+import { JsonType } from '../types/JsonType';
 import { MessageType } from '../types/ocpp/MessageType';
 import OCPP16IncomingRequestService from './ocpp/1.6/OCPP16IncomingRequestService';
 import OCPP16RequestService from './ocpp/1.6/OCPP16RequestService';
 import OCPP16ResponseService from './ocpp/1.6/OCPP16ResponseService';
-import OCPPError from './ocpp/OCPPError';
+import OCPPError from '../exception/OCPPError';
 import OCPPIncomingRequestService from './ocpp/OCPPIncomingRequestService';
 import OCPPRequestService from './ocpp/OCPPRequestService';
 import { OCPPVersion } from '../types/ocpp/OCPPVersion';
 import PerformanceStatistics from '../performance/PerformanceStatistics';
+import { SampledValueTemplate } from '../types/MeasurandPerPhaseSampledValueTemplates';
 import { StopTransactionReason } from '../types/ocpp/Transaction';
+import { SupervisionUrlDistribution } from '../types/ConfigurationData';
 import { URL } from 'url';
 import Utils from '../utils/Utils';
 import crypto from 'crypto';
@@ -39,6 +42,7 @@ import { parentPort } from 'worker_threads';
 import path from 'path';
 
 export default class ChargingStation {
+  public readonly id: string;
   public readonly stationTemplateFile: string;
   public authorizedTags: string[];
   public stationInfo!: ChargingStationInfo;
@@ -63,18 +67,16 @@ export default class ChargingStation {
   private webSocketPingSetInterval!: NodeJS.Timeout;
 
   constructor(index: number, stationTemplateFile: string) {
+    this.id = Utils.generateUUID();
     this.index = index;
     this.stationTemplateFile = stationTemplateFile;
-    this.connectors = new Map<number, ConnectorStatus>();
-    this.initialize();
-
     this.stopped = false;
     this.wsConnectionRestarted = false;
     this.autoReconnectRetryCount = 0;
-
+    this.connectors = new Map<number, ConnectorStatus>();
     this.requests = new Map<string, CachedRequest>();
     this.messageBuffer = new Set<string>();
-
+    this.initialize();
     this.authorizedTags = this.getAuthorizedTags();
   }
 
@@ -117,11 +119,31 @@ export default class ChargingStation {
   }
 
   public isWebSocketConnectionOpened(): boolean {
-    return this.wsConnection?.readyState === OPEN;
+    return this?.wsConnection?.readyState === OPEN;
+  }
+
+  public getRegistrationStatus(): RegistrationStatus {
+    return this?.bootNotificationResponse?.status;
+  }
+
+  public isInUnknownState(): boolean {
+    return Utils.isNullOrUndefined(this?.bootNotificationResponse?.status);
+  }
+
+  public isInPendingState(): boolean {
+    return this?.bootNotificationResponse?.status === RegistrationStatus.PENDING;
+  }
+
+  public isInAcceptedState(): boolean {
+    return this?.bootNotificationResponse?.status === RegistrationStatus.ACCEPTED;
+  }
+
+  public isInRejectedState(): boolean {
+    return this?.bootNotificationResponse?.status === RegistrationStatus.REJECTED;
   }
 
   public isRegistered(): boolean {
-    return this.bootNotificationResponse?.status === RegistrationStatus.ACCEPTED;
+    return !this.isInUnknownState() && (this.isInAcceptedState() || this.isInPendingState());
   }
 
   public isChargingStationAvailable(): boolean {
@@ -129,7 +151,7 @@ export default class ChargingStation {
   }
 
   public isConnectorAvailable(id: number): boolean {
-    return this.getConnectorStatus(id).availability === AvailabilityType.OPERATIVE;
+    return id > 0 && this.getConnectorStatus(id).availability === AvailabilityType.OPERATIVE;
   }
 
   public getNumberOfConnectors(): number {
@@ -144,6 +166,10 @@ export default class ChargingStation {
     return this.stationInfo.currentOutType ?? CurrentType.AC;
   }
 
+  public getOcppStrictCompliance(): boolean {
+    return this.stationInfo.ocppStrictCompliance ?? false;
+  }
+
   public getVoltageOut(): number | undefined {
     const errMsg = `${this.logPrefix()} Unknown ${this.getCurrentOutType()} currentOutType in template file ${this.stationTemplateFile}, cannot define default voltage out`;
     let defaultVoltageOut: number;
@@ -460,6 +486,10 @@ export default class ChargingStation {
     } catch (error) {
       FileUtils.handleFileException(this.logPrefix(), 'Template', this.stationTemplateFile, error as NodeJS.ErrnoException);
     }
+    const chargingStationId = this.getChargingStationId(stationTemplateFromFile);
+    // Deprecation template keys section
+    this.warnDeprecatedTemplateKey(stationTemplateFromFile, 'supervisionUrl', chargingStationId, 'Use \'supervisionUrls\' instead');
+    this.convertDeprecatedTemplateKey(stationTemplateFromFile, 'supervisionUrl', 'supervisionUrls');
     const stationInfo: ChargingStationInfo = stationTemplateFromFile ?? {} as ChargingStationInfo;
     stationInfo.wsOptions = stationTemplateFromFile?.wsOptions ?? {};
     if (!Utils.isEmptyArray(stationTemplateFromFile.power)) {
@@ -476,7 +506,7 @@ export default class ChargingStation {
     }
     delete stationInfo.power;
     delete stationInfo.powerUnit;
-    stationInfo.chargingStationId = this.getChargingStationId(stationTemplateFromFile);
+    stationInfo.chargingStationId = chargingStationId;
     stationInfo.resetTime = stationTemplateFromFile.resetTime ? stationTemplateFromFile.resetTime * 1000 : Constants.CHARGING_STATION_DEFAULT_RESET_TIME;
     return stationInfo;
   }
@@ -558,8 +588,8 @@ export default class ChargingStation {
     this.wsConfiguredConnectionUrl = new URL(this.getConfiguredSupervisionUrl().href + '/' + this.stationInfo.chargingStationId);
     switch (this.getOcppVersion()) {
       case OCPPVersion.VERSION_16:
-        this.ocppIncomingRequestService = new OCPP16IncomingRequestService(this);
-        this.ocppRequestService = new OCPP16RequestService(this, new OCPP16ResponseService(this));
+        this.ocppIncomingRequestService = OCPP16IncomingRequestService.getInstance<OCPP16IncomingRequestService>(this);
+        this.ocppRequestService = OCPP16RequestService.getInstance<OCPP16RequestService>(this, OCPP16ResponseService.getInstance<OCPP16ResponseService>(this));
         break;
       default:
         this.handleUnsupportedVersion(this.getOcppVersion());
@@ -576,7 +606,7 @@ export default class ChargingStation {
     }
     this.stationInfo.powerDivider = this.getPowerDivider();
     if (this.getEnableStatistics()) {
-      this.performanceStatistics = new PerformanceStatistics(this.stationInfo.chargingStationId, this.wsConnectionUrl);
+      this.performanceStatistics = PerformanceStatistics.getInstance(this.id, this.stationInfo.chargingStationId, this.wsConnectionUrl);
     }
   }
 
@@ -622,19 +652,19 @@ export default class ChargingStation {
 
   private async onOpen(): Promise<void> {
     logger.info(`${this.logPrefix()} Connected to OCPP server through ${this.wsConnectionUrl.toString()}`);
-    if (!this.isRegistered()) {
+    if (!this.isInAcceptedState()) {
       // Send BootNotification
       let registrationRetryCount = 0;
       do {
         this.bootNotificationResponse = await this.ocppRequestService.sendBootNotification(this.bootNotificationRequest.chargePointModel,
           this.bootNotificationRequest.chargePointVendor, this.bootNotificationRequest.chargeBoxSerialNumber, this.bootNotificationRequest.firmwareVersion);
-        if (!this.isRegistered()) {
-          registrationRetryCount++;
+        if (!this.isInAcceptedState()) {
+          this.getRegistrationMaxRetries() !== -1 && registrationRetryCount++;
           await Utils.sleep(this.bootNotificationResponse?.interval ? this.bootNotificationResponse.interval * 1000 : Constants.OCPP_DEFAULT_BOOT_NOTIFICATION_INTERVAL);
         }
-      } while (!this.isRegistered() && (registrationRetryCount <= this.getRegistrationMaxRetries() || this.getRegistrationMaxRetries() === -1));
+      } while (!this.isInAcceptedState() && (registrationRetryCount <= this.getRegistrationMaxRetries() || this.getRegistrationMaxRetries() === -1));
     }
-    if (this.isRegistered()) {
+    if (this.isInAcceptedState()) {
       await this.startMessageSequence();
       this.stopped && (this.stopped = false);
       if (this.wsConnectionRestarted && this.isWebSocketConnectionOpened()) {
@@ -665,10 +695,10 @@ export default class ChargingStation {
 
   private async onMessage(data: Data): Promise<void> {
     let [messageType, messageId, commandName, commandPayload, errorDetails]: IncomingRequest = [0, '', '' as IncomingRequestCommand, {}, {}];
-    let responseCallback: (payload: Record<string, unknown> | string, requestPayload: Record<string, unknown>) => void;
+    let responseCallback: (payload: JsonType | string, requestPayload: JsonType | OCPPError) => void;
     let rejectCallback: (error: OCPPError, requestStatistic?: boolean) => void;
     let requestCommandName: RequestCommand | IncomingRequestCommand;
-    let requestPayload: Record<string, unknown>;
+    let requestPayload: JsonType | OCPPError;
     let cachedRequest: CachedRequest;
     let errMsg: string;
     try {
@@ -829,7 +859,7 @@ export default class ChargingStation {
   }
 
   private getMaxNumberOfConnectors(): number {
-    let maxConnectors = 0;
+    let maxConnectors: number;
     if (!Utils.isEmptyArray(this.stationInfo.numberOfConnectors)) {
       const numberOfConnectors = this.stationInfo.numberOfConnectors as number[];
       // Distribute evenly the number of connectors
@@ -843,6 +873,10 @@ export default class ChargingStation {
   }
 
   private async startMessageSequence(): Promise<void> {
+    if (this.stationInfo.autoRegister) {
+      await this.ocppRequestService.sendBootNotification(this.bootNotificationRequest.chargePointModel,
+        this.bootNotificationRequest.chargePointVendor, this.bootNotificationRequest.chargeBoxSerialNumber, this.bootNotificationRequest.firmwareVersion);
+    }
     // Start WebSocket ping
     this.startWebSocketPing();
     // Start heartbeat
@@ -890,8 +924,7 @@ export default class ChargingStation {
     this.stopHeartbeat();
     // Stop the ATG
     if (this.stationInfo.AutomaticTransactionGenerator.enable &&
-      this.automaticTransactionGenerator &&
-      this.automaticTransactionGenerator.started) {
+      this.automaticTransactionGenerator?.started) {
       this.automaticTransactionGenerator.stop();
     } else {
       for (const connectorId of this.connectors.keys()) {
@@ -928,17 +961,45 @@ export default class ChargingStation {
     }
   }
 
+  private warnDeprecatedTemplateKey(template: ChargingStationTemplate, key: string, chargingStationId: string, logMsgToAppend = ''): void {
+    if (!Utils.isUndefined(template[key])) {
+      logger.warn(`${Utils.logPrefix(` ${chargingStationId} |`)} Deprecated template key '${key}' usage in file '${this.stationTemplateFile}'${logMsgToAppend && '. ' + logMsgToAppend}`);
+    }
+  }
+
+  private convertDeprecatedTemplateKey(template: ChargingStationTemplate, deprecatedKey: string, key: string): void {
+    if (!Utils.isUndefined(template[deprecatedKey])) {
+      // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
+      template[key] = template[deprecatedKey];
+      delete template[deprecatedKey];
+    }
+  }
+
   private getConfiguredSupervisionUrl(): URL {
-    const supervisionUrls = Utils.cloneObject<string | string[]>(this.stationInfo.supervisionUrl ?? Configuration.getSupervisionUrls());
-    let indexUrl = 0;
+    const supervisionUrls = Utils.cloneObject<string | string[]>(this.stationInfo.supervisionUrls ?? Configuration.getSupervisionUrls());
     if (!Utils.isEmptyArray(supervisionUrls)) {
-      if (Configuration.getDistributeStationsToTenantsEqually()) {
-        indexUrl = this.index % supervisionUrls.length;
-      } else {
-        // Get a random url
-        indexUrl = Math.floor(Utils.secureRandom() * supervisionUrls.length);
+      let urlIndex = 0;
+      switch (Configuration.getSupervisionUrlDistribution()) {
+        case SupervisionUrlDistribution.ROUND_ROBIN:
+          urlIndex = (this.index - 1) % supervisionUrls.length;
+          break;
+        case SupervisionUrlDistribution.RANDOM:
+          // Get a random url
+          urlIndex = Math.floor(Utils.secureRandom() * supervisionUrls.length);
+          break;
+        case SupervisionUrlDistribution.SEQUENTIAL:
+          if (this.index <= supervisionUrls.length) {
+            urlIndex = this.index - 1;
+          } else {
+            logger.warn(`${this.logPrefix()} No more configured supervision urls available, using the first one`);
+          }
+          break;
+        default:
+          logger.error(`${this.logPrefix()} Unknown supervision url distribution '${Configuration.getSupervisionUrlDistribution()}' from values '${SupervisionUrlDistribution.toString()}', defaulting to ${SupervisionUrlDistribution.ROUND_ROBIN}`);
+          urlIndex = (this.index - 1) % supervisionUrls.length;
+          break;
       }
-      return new URL(supervisionUrls[indexUrl]);
+      return new URL(supervisionUrls[urlIndex]);
     }
     return new URL(supervisionUrls as string);
   }
@@ -1054,8 +1115,7 @@ export default class ChargingStation {
     // Stop the ATG if needed
     if (this.stationInfo.AutomaticTransactionGenerator.enable &&
       this.stationInfo.AutomaticTransactionGenerator.stopOnConnectionFailure &&
-      this.automaticTransactionGenerator &&
-      this.automaticTransactionGenerator.started) {
+      this.automaticTransactionGenerator?.started) {
       this.automaticTransactionGenerator.stop();
     }
     if (this.autoReconnectRetryCount < this.getAutoReconnectMaxRetries() || this.getAutoReconnectMaxRetries() === -1) {