Consistently name charging stations run on CF on multiple instances
[e-mobility-charging-stations-simulator.git] / src / charging-station / ChargingStation.ts
index 2626a23353c0815cea003ea59787c7cc81b5b662..67bf3b627042e9c63bfc1e83a7df5ba07369558b 100644 (file)
@@ -1,32 +1,36 @@
+// Partial Copyright Jerome Benoit. 2021. All Rights Reserved.
+
+import { AvailabilityType, BootNotificationRequest, IncomingRequest, IncomingRequestCommand, Request } from '../types/ocpp/Requests';
 import { BootNotificationResponse, RegistrationStatus } from '../types/ocpp/Responses';
 import ChargingStationConfiguration, { ConfigurationKey } from '../types/ChargingStationConfiguration';
 import ChargingStationTemplate, { CurrentType, PowerUnits, Voltage } from '../types/ChargingStationTemplate';
 import { ConnectorPhaseRotation, StandardParametersKey, SupportedFeatureProfiles } from '../types/ocpp/Configuration';
 import Connectors, { Connector, SampledValueTemplate } from '../types/Connectors';
 import { MeterValueMeasurand, MeterValuePhase } from '../types/ocpp/MeterValues';
-import Requests, { AvailabilityType, BootNotificationRequest, IncomingRequest, IncomingRequestCommand } from '../types/ocpp/Requests';
-import WebSocket, { MessageEvent } from 'ws';
+import { WSError, WebSocketCloseEventStatusCode } from '../types/WebSocket';
+import WebSocket, { ClientOptions, Data } from 'ws';
 
 import AutomaticTransactionGenerator from './AutomaticTransactionGenerator';
 import { ChargePointStatus } from '../types/ocpp/ChargePointStatus';
 import { ChargingProfile } from '../types/ocpp/ChargingProfile';
 import ChargingStationInfo from '../types/ChargingStationInfo';
+import { ClientRequestArgs } from 'http';
 import Configuration from '../utils/Configuration';
 import Constants from '../utils/Constants';
+import { ErrorType } from '../types/ocpp/ErrorType';
 import FileUtils from '../utils/FileUtils';
 import { MessageType } from '../types/ocpp/MessageType';
-import OCPP16IncomingRequestService from './ocpp/1.6/OCCP16IncomingRequestService';
+import OCPP16IncomingRequestService from './ocpp/1.6/OCPP16IncomingRequestService';
 import OCPP16RequestService from './ocpp/1.6/OCPP16RequestService';
 import OCPP16ResponseService from './ocpp/1.6/OCPP16ResponseService';
-import OCPPError from './OCPPError';
+import OCPPError from './ocpp/OCPPError';
 import OCPPIncomingRequestService from './ocpp/OCPPIncomingRequestService';
 import OCPPRequestService from './ocpp/OCPPRequestService';
 import { OCPPVersion } from '../types/ocpp/OCPPVersion';
-import PerformanceStatistics from '../utils/PerformanceStatistics';
+import PerformanceStatistics from '../performance/PerformanceStatistics';
 import { StopTransactionReason } from '../types/ocpp/Transaction';
 import { URL } from 'url';
 import Utils from '../utils/Utils';
-import { WebSocketCloseEventStatusCode } from '../types/WebSocket';
 import crypto from 'crypto';
 import fs from 'fs';
 import logger from '../utils/Logger';
@@ -40,7 +44,7 @@ export default class ChargingStation {
   public configuration!: ChargingStationConfiguration;
   public hasStopped: boolean;
   public wsConnection!: WebSocket;
-  public requests: Requests;
+  public requests: Map<string, Request>;
   public messageQueue: string[];
   public performanceStatistics!: PerformanceStatistics;
   public heartbeatSetInterval!: NodeJS.Timeout;
@@ -66,8 +70,8 @@ export default class ChargingStation {
     this.hasSocketRestarted = false;
     this.autoReconnectRetryCount = 0;
 
-    this.requests = {} as Requests;
-    this.messageQueue = [] as string[];
+    this.requests = new Map<string, Request>();
+    this.messageQueue = new Array<string>();
 
     this.authorizedTags = this.getAuthorizedTags();
   }
@@ -76,6 +80,10 @@ export default class ChargingStation {
     return Utils.logPrefix(` ${this.stationInfo.chargingStationId} |`);
   }
 
+  public getBootNotificationRequest(): BootNotificationRequest {
+    return this.bootNotificationRequest;
+  }
+
   public getRandomTagId(): string {
     const index = Math.floor(Math.random() * this.authorizedTags.length);
     return this.authorizedTags[index];
@@ -102,7 +110,7 @@ export default class ChargingStation {
     }
   }
 
-  public isWebSocketOpen(): boolean {
+  public isWebSocketConnectionOpened(): boolean {
     return this.wsConnection?.readyState === WebSocket.OPEN;
   }
 
@@ -228,9 +236,8 @@ export default class ChargingStation {
     for (let index = 0; !Utils.isEmptyArray(sampledValueTemplates) && index < sampledValueTemplates.length; index++) {
       if (!Constants.SUPPORTED_MEASURANDS.includes(sampledValueTemplates[index]?.measurand ?? MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER)) {
         logger.warn(`${this.logPrefix()} Unsupported MeterValues measurand ${measurand} ${phase ? `on phase ${phase} ` : ''}in template on connectorId ${connectorId}`);
-        continue;
       } else if (phase && sampledValueTemplates[index]?.phase === phase && sampledValueTemplates[index]?.measurand === measurand
-          && this.getConfigurationKey(StandardParametersKey.MeterValuesSampledData).value.includes(measurand)) {
+                 && this.getConfigurationKey(StandardParametersKey.MeterValuesSampledData).value.includes(measurand)) {
         return sampledValueTemplates[index];
       } else if (!phase && !sampledValueTemplates[index].phase && sampledValueTemplates[index]?.measurand === measurand
                  && this.getConfigurationKey(StandardParametersKey.MeterValuesSampledData).value.includes(measurand)) {
@@ -241,7 +248,9 @@ export default class ChargingStation {
       }
     }
     if (measurand === MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER) {
-      logger.error(`${this.logPrefix()} Missing MeterValues for default measurand ${measurand} in template on connectorId ${connectorId}`);
+      const errorMsg = `${this.logPrefix()} Missing MeterValues for default measurand ${measurand} in template on connectorId ${connectorId}`;
+      logger.error(errorMsg);
+      throw new Error(errorMsg);
     }
     logger.debug(`${this.logPrefix()} No MeterValues for measurand ${measurand} ${phase ? `on phase ${phase} ` : ''}in template on connectorId ${connectorId}`);
   }
@@ -290,7 +299,7 @@ export default class ChargingStation {
     if (interval > 0) {
       // eslint-disable-next-line @typescript-eslint/no-misused-promises
       this.getConnector(connectorId).transactionSetInterval = setInterval(async (): Promise<void> => {
-        await this.ocppRequestService.sendMeterValues(connectorId, this.getConnector(connectorId).transactionId, interval, this.ocppRequestService);
+        await this.ocppRequestService.sendMeterValues(connectorId, this.getConnector(connectorId).transactionId, interval);
       }, interval);
     } else {
       logger.error(`${this.logPrefix()} Charging station ${StandardParametersKey.MeterValueSampleInterval} configuration set to ${interval ? Utils.milliSecondsToHHMMSS(interval) : interval}, not sending MeterValues`);
@@ -298,6 +307,9 @@ export default class ChargingStation {
   }
 
   public start(): void {
+    if (this.getEnableStatistics()) {
+      this.performanceStatistics.start();
+    }
     this.openWSConnection();
     // Monitor authorization file
     this.startAuthorizationFileMonitoring();
@@ -326,21 +338,23 @@ export default class ChargingStation {
         this.getConnector(Utils.convertToInt(connector)).status = ChargePointStatus.UNAVAILABLE;
       }
     }
-    if (this.isWebSocketOpen()) {
+    if (this.isWebSocketConnectionOpened()) {
       this.wsConnection.close();
     }
+    if (this.getEnableStatistics()) {
+      this.performanceStatistics.stop();
+    }
     this.bootNotificationResponse = null;
     this.hasStopped = true;
   }
 
   public getConfigurationKey(key: string | StandardParametersKey, caseInsensitive = false): ConfigurationKey | undefined {
-    const configurationKey: ConfigurationKey | undefined = this.configuration.configurationKey.find((configElement) => {
+    return this.configuration.configurationKey.find((configElement) => {
       if (caseInsensitive) {
         return configElement.key.toLowerCase() === key.toLowerCase();
       }
       return configElement.key === key;
     });
-    return configurationKey;
   }
 
   public addConfigurationKey(key: string | StandardParametersKey, value: string, readonly = false, visible = true, reboot = false): void {
@@ -413,6 +427,7 @@ export default class ChargingStation {
     if (!Utils.isEmptyArray(this.messageQueue)) {
       this.messageQueue.forEach((message, index) => {
         this.messageQueue.splice(index, 1);
+        // TODO: evaluate the need to track performance
         this.wsConnection.send(message);
       });
     }
@@ -420,8 +435,7 @@ export default class ChargingStation {
 
   private getChargingStationId(stationTemplate: ChargingStationTemplate): string {
     // In case of multiple instances: add instance index to charging station id
-    let instanceIndex = process.env.CF_INSTANCE_INDEX ?? 0;
-    instanceIndex = instanceIndex > 0 ? instanceIndex : '';
+    const instanceIndex = process.env.CF_INSTANCE_INDEX ?? 0;
     const idSuffix = stationTemplate.nameSuffix ?? '';
     return stationTemplate.fixedName ? stationTemplate.baseName : stationTemplate.baseName + '-' + instanceIndex.toString() + ('000000000' + this.index.toString()).substr(('000000000' + this.index.toString()).length - 4) + idSuffix;
   }
@@ -548,7 +562,7 @@ export default class ChargingStation {
     }
     this.stationInfo.powerDivider = this.getPowerDivider();
     if (this.getEnableStatistics()) {
-      this.performanceStatistics = new PerformanceStatistics(this.stationInfo.chargingStationId);
+      this.performanceStatistics = new PerformanceStatistics(this.stationInfo.chargingStationId, this.wsConnectionUrl);
     }
   }
 
@@ -606,7 +620,7 @@ export default class ChargingStation {
     if (this.isRegistered()) {
       await this.startMessageSequence();
       this.hasStopped && (this.hasStopped = false);
-      if (this.hasSocketRestarted && this.isWebSocketOpen()) {
+      if (this.hasSocketRestarted && this.isWebSocketConnectionOpened()) {
         this.flushMessageQueue();
       }
     } else {
@@ -616,40 +630,43 @@ export default class ChargingStation {
     this.hasSocketRestarted = false;
   }
 
-  private async onClose(closeEvent: any): Promise<void> {
-    switch (closeEvent) {
-      case WebSocketCloseEventStatusCode.CLOSE_NORMAL: // Normal close
+  private async onClose(code: number, reason: string): Promise<void> {
+    switch (code) {
+      // Normal close
+      case WebSocketCloseEventStatusCode.CLOSE_NORMAL:
       case WebSocketCloseEventStatusCode.CLOSE_NO_STATUS:
-        logger.info(`${this.logPrefix()} Socket normally closed with status '${Utils.getWebSocketCloseEventStatusString(closeEvent)}'`);
+        logger.info(`${this.logPrefix()} Socket normally closed with status '${Utils.getWebSocketCloseEventStatusString(code)}' and reason '${reason}'`);
         this.autoReconnectRetryCount = 0;
         break;
-      default: // Abnormal close
-        logger.error(`${this.logPrefix()} Socket abnormally closed with status '${Utils.getWebSocketCloseEventStatusString(closeEvent)}'`);
-        await this.reconnect(closeEvent);
+      // Abnormal close
+      default:
+        logger.error(`${this.logPrefix()} Socket abnormally closed with status '${Utils.getWebSocketCloseEventStatusString(code)}' and reason '${reason}'`);
+        await this.reconnect(code);
         break;
     }
   }
 
-  private async onMessage(messageEvent: MessageEvent): Promise<void> {
+  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 rejectCallback: (error: OCPPError) => void;
     let requestPayload: Record<string, unknown>;
+    let cachedRequest: Request;
     let errMsg: string;
     try {
-      const request = JSON.parse(messageEvent.toString()) as IncomingRequest;
+      const request = JSON.parse(data.toString()) as IncomingRequest;
       if (Utils.isIterable(request)) {
         // Parse the message
         [messageType, messageId, commandName, commandPayload, errorDetails] = request;
       } else {
-        throw new Error('Incoming request is not iterable');
+        throw new OCPPError(ErrorType.PROTOCOL_ERROR, 'Incoming request is not iterable', commandName);
       }
       // Check the Type of message
       switch (messageType) {
         // Incoming Message
         case MessageType.CALL_MESSAGE:
           if (this.getEnableStatistics()) {
-            this.performanceStatistics.addMessage(commandName, messageType);
+            this.performanceStatistics.addRequestStatistic(commandName, messageType);
           }
           // Process the call
           await this.ocppIncomingRequestService.handleRequest(messageId, commandName, commandPayload);
@@ -657,41 +674,43 @@ export default class ChargingStation {
         // Outcome Message
         case MessageType.CALL_RESULT_MESSAGE:
           // Respond
-          if (Utils.isIterable(this.requests[messageId])) {
-            [responseCallback, , requestPayload] = this.requests[messageId];
+          cachedRequest = this.requests.get(messageId);
+          if (Utils.isIterable(cachedRequest)) {
+            [responseCallback, , requestPayload] = cachedRequest;
           } else {
-            throw new Error(`Response request for message id ${messageId} is not iterable`);
+            throw new OCPPError(ErrorType.PROTOCOL_ERROR, `Response request for message id ${messageId} is not iterable`, commandName);
           }
           if (!responseCallback) {
             // Error
-            throw new Error(`Response request for unknown message id ${messageId}`);
+            throw new OCPPError(ErrorType.INTERNAL_ERROR, `Response request for unknown message id ${messageId}`, commandName);
           }
-          delete this.requests[messageId];
+          this.requests.delete(messageId);
           responseCallback(commandName, requestPayload);
           break;
         // Error Message
         case MessageType.CALL_ERROR_MESSAGE:
-          if (!this.requests[messageId]) {
+          cachedRequest = this.requests.get(messageId);
+          if (!cachedRequest) {
             // Error
-            throw new Error(`Error request for unknown message id ${messageId}`);
+            throw new OCPPError(ErrorType.INTERNAL_ERROR, `Error request for unknown message id ${messageId}`);
           }
-          if (Utils.isIterable(this.requests[messageId])) {
-            [, rejectCallback] = this.requests[messageId];
+          if (Utils.isIterable(cachedRequest)) {
+            [, rejectCallback] = cachedRequest;
           } else {
-            throw new Error(`Error request for message id ${messageId} is not iterable`);
+            throw new OCPPError(ErrorType.PROTOCOL_ERROR, `Error request for message id ${messageId} is not iterable`);
           }
-          delete this.requests[messageId];
-          rejectCallback(new OCPPError(commandName, commandPayload.toString(), errorDetails));
+          this.requests.delete(messageId);
+          rejectCallback(new OCPPError(commandName, commandPayload.toString(), null, errorDetails));
           break;
         // Error
         default:
           errMsg = `${this.logPrefix()} Wrong message type ${messageType}`;
           logger.error(errMsg);
-          throw new Error(errMsg);
+          throw new OCPPError(ErrorType.PROTOCOL_ERROR, errMsg);
       }
     } catch (error) {
       // Log
-      logger.error('%s Incoming request message %j processing error %j on content type %j', this.logPrefix(), messageEvent, error, this.requests[messageId]);
+      logger.error('%s Incoming request message %j processing error %j on content type %j', this.logPrefix(), data, error, this.requests.get(messageId));
       // Send error
       messageType !== MessageType.CALL_ERROR_MESSAGE && await this.ocppRequestService.sendError(messageId, error, commandName);
     }
@@ -705,17 +724,17 @@ export default class ChargingStation {
     logger.debug(this.logPrefix() + ' Received a WS pong (rfc6455) from the server');
   }
 
-  private async onError(errorEvent: any): Promise<void> {
-    logger.error(this.logPrefix() + ' Socket error: %j', errorEvent);
-    // switch (errorEvent.code) {
+  private async onError(error: WSError): Promise<void> {
+    logger.error(this.logPrefix() + ' Socket error: %j', error);
+    // switch (error.code) {
     //   case 'ECONNREFUSED':
-    //     await this._reconnect(errorEvent);
+    //     await this.reconnect(error);
     //     break;
     // }
   }
 
   private getTemplateChargingStationConfiguration(): ChargingStationConfiguration {
-    return this.stationInfo.Configuration ? this.stationInfo.Configuration : {} as ChargingStationConfiguration;
+    return this.stationInfo.Configuration ?? {} as ChargingStationConfiguration;
   }
 
   private getAuthorizationFile(): string | undefined {
@@ -839,9 +858,6 @@ export default class ChargingStation {
     }
     // Start the ATG
     this.startAutomaticTransactionGenerator();
-    if (this.getEnableStatistics()) {
-      this.performanceStatistics.start();
-    }
   }
 
   private startAutomaticTransactionGenerator() {
@@ -851,7 +867,7 @@ export default class ChargingStation {
       }
       if (this.automaticTransactionGeneration.timeToStop) {
         // The ATG might sleep
-        void this.automaticTransactionGeneration.start();
+        this.automaticTransactionGeneration.start().catch(() => { });
       }
     }
   }
@@ -883,7 +899,7 @@ export default class ChargingStation {
       : 0;
     if (webSocketPingInterval > 0 && !this.webSocketPingSetInterval) {
       this.webSocketPingSetInterval = setInterval(() => {
-        if (this.isWebSocketOpen()) {
+        if (this.isWebSocketConnectionOpened()) {
           this.wsConnection.ping((): void => { });
         }
       }, webSocketPingInterval * 1000);
@@ -935,10 +951,13 @@ export default class ChargingStation {
     }
   }
 
-  private openWSConnection(options?: WebSocket.ClientOptions, forceCloseOpened = false): void {
-    options ?? {} as WebSocket.ClientOptions;
-    options?.handshakeTimeout ?? this.getConnectionTimeout() * 1000;
-    if (this.isWebSocketOpen() && forceCloseOpened) {
+  private openWSConnection(options?: ClientOptions & ClientRequestArgs, forceCloseOpened = false): void {
+    options = options ?? {};
+    options.handshakeTimeout = options?.handshakeTimeout ?? this.getConnectionTimeout() * 1000;
+    if (!Utils.isNullOrUndefined(this.stationInfo.supervisionUser) && !Utils.isNullOrUndefined(this.stationInfo.supervisionPassword)) {
+      options.auth = `${this.stationInfo.supervisionUser}:${this.stationInfo.supervisionPassword}`;
+    }
+    if (this.isWebSocketConnectionOpened() && forceCloseOpened) {
       this.wsConnection.close();
     }
     let protocol;
@@ -964,13 +983,15 @@ export default class ChargingStation {
     const authorizationFile = this.getAuthorizationFile();
     if (authorizationFile) {
       try {
-        fs.watch(authorizationFile).on('change', () => {
-          try {
-            logger.debug(this.logPrefix() + ' Authorization file ' + authorizationFile + ' have changed, reload');
-            // Initialize authorizedTags
-            this.authorizedTags = this.getAuthorizedTags();
-          } catch (error) {
-            logger.error(this.logPrefix() + ' Authorization file monitoring error: %j', error);
+        fs.watch(authorizationFile, (event, filename) => {
+          if (filename && event === 'change') {
+            try {
+              logger.debug(this.logPrefix() + ' Authorization file ' + authorizationFile + ' have changed, reload');
+              // Initialize authorizedTags
+              this.authorizedTags = this.getAuthorizedTags();
+            } catch (error) {
+              logger.error(this.logPrefix() + ' Authorization file monitoring error: %j', error);
+            }
           }
         });
       } catch (error) {
@@ -983,22 +1004,27 @@ export default class ChargingStation {
 
   private startStationTemplateFileMonitoring(): void {
     try {
-      // eslint-disable-next-line @typescript-eslint/no-misused-promises
-      fs.watch(this.stationTemplateFile).on('change', async (): Promise<void> => {
-        try {
-          logger.debug(this.logPrefix() + ' Template file ' + this.stationTemplateFile + ' have changed, reload');
-          // Initialize
-          this.initialize();
-          // Stop the ATG
-          if (!this.stationInfo.AutomaticTransactionGenerator.enable &&
-          this.automaticTransactionGeneration) {
-            await this.automaticTransactionGeneration.stop();
+      fs.watch(this.stationTemplateFile, async (event, filename): Promise<void> => {
+        if (filename && event === 'change') {
+          try {
+            logger.debug(this.logPrefix() + ' Template file ' + this.stationTemplateFile + ' have changed, reload');
+            // Initialize
+            this.initialize();
+            // Restart the ATG
+            if (!this.stationInfo.AutomaticTransactionGenerator.enable &&
+                this.automaticTransactionGeneration) {
+              await this.automaticTransactionGeneration.stop();
+            }
+            this.startAutomaticTransactionGenerator();
+            if (this.getEnableStatistics()) {
+              this.performanceStatistics.restart();
+            } else {
+              this.performanceStatistics.stop();
+            }
+            // FIXME?: restart heartbeat and WebSocket ping when their interval values have changed
+          } catch (error) {
+            logger.error(this.logPrefix() + ' Charging station template file monitoring error: %j', error);
           }
-          // Start the ATG
-          this.startAutomaticTransactionGenerator();
-          // FIXME?: restart heartbeat and WebSocket ping when their interval values have changed
-        } catch (error) {
-          logger.error(this.logPrefix() + ' Charging station template file monitoring error: %j', error);
         }
       });
     } catch (error) {
@@ -1010,7 +1036,9 @@ export default class ChargingStation {
     return !Utils.isUndefined(this.stationInfo.reconnectExponentialDelay) ? this.stationInfo.reconnectExponentialDelay : false;
   }
 
-  private async reconnect(error: any): Promise<void> {
+  private async reconnect(code: number): Promise<void> {
+    // Stop WebSocket ping
+    this.stopWebSocketPing();
     // Stop heartbeat
     this.stopHeartbeat();
     // Stop the ATG if needed