Implement singleton design pattern with strict null check
[e-mobility-charging-stations-simulator.git] / src / charging-station / ocpp / OCPPRequestService.ts
index 5d767f0cb0c6726e432d7fc7faf28a733a0b6293..6f4e47be04864463f34b6b89fa65bceae9cf614c 100644 (file)
-import { AuthorizeResponse, StartTransactionResponse, StopTransactionReason, StopTransactionResponse } from '../../types/ocpp/Transaction';
-import { DiagnosticsStatus, IncomingRequestCommand, RequestCommand } from '../../types/ocpp/Requests';
+import { JSONSchemaType } from 'ajv';
+import Ajv from 'ajv-draft-04';
+import ajvFormats from 'ajv-formats';
 
-import { BootNotificationResponse } from '../../types/ocpp/Responses';
-import { ChargePointErrorCode } from '../../types/ocpp/ChargePointErrorCode';
-import { ChargePointStatus } from '../../types/ocpp/ChargePointStatus';
-import ChargingStation from '../ChargingStation';
-import Constants from '../../utils/Constants';
+import OCPPError from '../../exception/OCPPError';
+import PerformanceStatistics from '../../performance/PerformanceStatistics';
+import { EmptyObject } from '../../types/EmptyObject';
+import { HandleErrorParams } from '../../types/Error';
+import { JsonObject, JsonType } from '../../types/JsonType';
 import { ErrorType } from '../../types/ocpp/ErrorType';
 import { MessageType } from '../../types/ocpp/MessageType';
-import { MeterValue } from '../../types/ocpp/MeterValues';
-import OCPPError from './OCPPError';
-import OCPPResponseService from './OCPPResponseService';
-import PerformanceStatistics from '../../performance/PerformanceStatistics';
-import Utils from '../../utils/Utils';
+import {
+  IncomingRequestCommand,
+  OutgoingRequest,
+  RequestCommand,
+  RequestParams,
+  ResponseType,
+} from '../../types/ocpp/Requests';
+import { ErrorResponse, Response } from '../../types/ocpp/Responses';
+import Constants from '../../utils/Constants';
 import logger from '../../utils/Logger';
+import Utils from '../../utils/Utils';
+import type ChargingStation from '../ChargingStation';
+import type OCPPResponseService from './OCPPResponseService';
+import { OCPPServiceUtils } from './OCPPServiceUtils';
+
+const moduleName = 'OCPPRequestService';
 
 export default abstract class OCPPRequestService {
-  public chargingStation: ChargingStation;
-  protected ocppResponseService: OCPPResponseService;
+  private static instance: OCPPRequestService | null = null;
+  private ajv: Ajv;
+
+  private readonly ocppResponseService: OCPPResponseService;
 
-  constructor(chargingStation: ChargingStation, ocppResponseService: OCPPResponseService) {
-    this.chargingStation = chargingStation;
+  protected constructor(ocppResponseService: OCPPResponseService) {
     this.ocppResponseService = ocppResponseService;
+    this.requestHandler.bind(this);
+    this.sendResponse.bind(this);
+    this.sendError.bind(this);
+    this.ajv = new Ajv();
+    ajvFormats(this.ajv);
   }
 
-  public async sendMessage(messageId: string, commandParams: any, messageType: MessageType, commandName: RequestCommand | IncomingRequestCommand,
-      skipBufferingOnError = false): Promise<unknown> {
-    // eslint-disable-next-line @typescript-eslint/no-this-alias
-    const self = this;
-    // Send a message through wsConnection
-    return Utils.promiseWithTimeout(new Promise((resolve, reject) => {
-      const messageToSend = this.buildMessageToSend(messageId, commandParams, messageType, commandName, responseCallback, rejectCallback);
-      if (this.chargingStation.getEnableStatistics()) {
-        this.chargingStation.performanceStatistics.addRequestStatistic(commandName, messageType);
-      }
-      // Check if wsConnection opened
-      if (this.chargingStation.isWebSocketConnectionOpened()) {
-        // Yes: Send Message
-        const beginId = PerformanceStatistics.beginMeasure(commandName);
-        // FIXME: Handle sending error
-        this.chargingStation.wsConnection.send(messageToSend);
-        PerformanceStatistics.endMeasure(commandName, beginId);
-      } else if (!skipBufferingOnError) {
-        // Buffer it
-        this.chargingStation.bufferMessage(messageToSend);
-        const ocppError = new OCPPError(ErrorType.GENERIC_ERROR, `WebSocket closed for buffered message id '${messageId}' with content '${messageToSend}'`, commandParams?.details ?? {});
-        if (messageType === MessageType.CALL_MESSAGE) {
-          // Reject it but keep the request in the cache
-          return reject(ocppError);
-        }
-        return rejectCallback(ocppError, false);
-      } else {
-        // Reject it
-        return rejectCallback(new OCPPError(ErrorType.GENERIC_ERROR, `WebSocket closed for non buffered message id '${messageId}' with content '${messageToSend}'`, commandParams?.details ?? {}), false);
-      }
-      // Response?
-      if (messageType !== MessageType.CALL_MESSAGE) {
-        // Yes: send Ok
-        return resolve(commandParams);
-      }
-
-      /**
-       * Function that will receive the request's response
-       *
-       * @param payload
-       * @param requestPayload
-       */
-      async function responseCallback(payload: Record<string, unknown> | string, requestPayload: Record<string, unknown>): Promise<void> {
-        if (self.chargingStation.getEnableStatistics()) {
-          self.chargingStation.performanceStatistics.addRequestStatistic(commandName, MessageType.CALL_RESULT_MESSAGE);
-        }
-        // Send the response
-        await self.ocppResponseService.handleResponse(commandName as RequestCommand, payload, requestPayload);
-        self.chargingStation.requests.delete(messageId);
-        resolve(payload);
-      }
-
-      /**
-       * Function that will receive the request's error response
-       *
-       * @param error
-       * @param requestStatistic
-       */
-      function rejectCallback(error: OCPPError, requestStatistic = true): void {
-        if (requestStatistic && self.chargingStation.getEnableStatistics()) {
-          self.chargingStation.performanceStatistics.addRequestStatistic(commandName, MessageType.CALL_ERROR_MESSAGE);
-        }
-        logger.error(`${self.chargingStation.logPrefix()} Error %j occurred when calling command %s with parameters %j`, error, commandName, commandParams);
-        self.chargingStation.requests.delete(messageId);
-        reject(error);
-      }
-    }), Constants.OCPP_WEBSOCKET_TIMEOUT, new OCPPError(ErrorType.GENERIC_ERROR, `Timeout for message id '${messageId}'`, commandParams?.details ?? {}), () => {
-      messageType === MessageType.CALL_MESSAGE && this.chargingStation.requests.delete(messageId);
-    });
+  public static getInstance<T extends OCPPRequestService>(
+    this: new (ocppResponseService: OCPPResponseService) => T,
+    ocppResponseService: OCPPResponseService
+  ): T {
+    if (OCPPRequestService.instance === null) {
+      OCPPRequestService.instance = new this(ocppResponseService);
+    }
+    return OCPPRequestService.instance as T;
+  }
+
+  public async sendResponse(
+    chargingStation: ChargingStation,
+    messageId: string,
+    messagePayload: JsonType,
+    commandName: IncomingRequestCommand
+  ): Promise<ResponseType> {
+    try {
+      // Send response message
+      return await this.internalSendMessage(
+        chargingStation,
+        messageId,
+        messagePayload,
+        MessageType.CALL_RESULT_MESSAGE,
+        commandName
+      );
+    } catch (error) {
+      this.handleRequestError(chargingStation, commandName, error as Error);
+    }
+  }
+
+  public async sendError(
+    chargingStation: ChargingStation,
+    messageId: string,
+    ocppError: OCPPError,
+    commandName: RequestCommand | IncomingRequestCommand
+  ): Promise<ResponseType> {
+    try {
+      // Send error message
+      return await this.internalSendMessage(
+        chargingStation,
+        messageId,
+        ocppError,
+        MessageType.CALL_ERROR_MESSAGE,
+        commandName
+      );
+    } catch (error) {
+      this.handleRequestError(chargingStation, commandName, error as Error);
+    }
   }
 
-  protected handleRequestError(commandName: RequestCommand, error: Error): void {
-    logger.error(this.chargingStation.logPrefix() + ' Request command ' + commandName + ' error: %j', error);
-    throw error;
+  protected async sendMessage(
+    chargingStation: ChargingStation,
+    messageId: string,
+    messagePayload: JsonType,
+    commandName: RequestCommand,
+    params: RequestParams = {
+      skipBufferingOnError: false,
+      triggerMessage: false,
+    }
+  ): Promise<ResponseType> {
+    try {
+      return await this.internalSendMessage(
+        chargingStation,
+        messageId,
+        messagePayload,
+        MessageType.CALL_MESSAGE,
+        commandName,
+        params
+      );
+    } catch (error) {
+      this.handleRequestError(chargingStation, commandName, error as Error, { throwError: false });
+    }
   }
 
-  private buildMessageToSend(messageId: string, commandParams: Record<string, unknown>, messageType: MessageType, commandName: RequestCommand | IncomingRequestCommand,
-      responseCallback: (payload: Record<string, unknown> | string, requestPayload: Record<string, unknown>) => Promise<void>,
-      rejectCallback: (error: OCPPError, requestStatistic?: boolean) => void): string {
+  protected validateRequestPayload<T extends JsonType>(
+    chargingStation: ChargingStation,
+    commandName: RequestCommand,
+    schema: JSONSchemaType<T>,
+    payload: T
+  ): boolean {
+    if (!chargingStation.getPayloadSchemaValidation()) {
+      return true;
+    }
+    const validate = this.ajv.compile(schema);
+    if (validate(payload)) {
+      return true;
+    }
+    logger.error(
+      `${chargingStation.logPrefix()} ${moduleName}.validateRequestPayload: Request PDU is invalid: %j`,
+      validate.errors
+    );
+    throw new OCPPError(
+      OCPPServiceUtils.ajvErrorsToErrorType(validate.errors),
+      'Request PDU is invalid',
+      commandName,
+      JSON.stringify(validate.errors, null, 2)
+    );
+  }
+
+  private async internalSendMessage(
+    chargingStation: ChargingStation,
+    messageId: string,
+    messagePayload: JsonType | OCPPError,
+    messageType: MessageType,
+    commandName?: RequestCommand | IncomingRequestCommand,
+    params: RequestParams = {
+      skipBufferingOnError: false,
+      triggerMessage: false,
+    }
+  ): Promise<ResponseType> {
+    if (
+      (chargingStation.isInUnknownState() && commandName === RequestCommand.BOOT_NOTIFICATION) ||
+      (!chargingStation.getOcppStrictCompliance() && chargingStation.isInUnknownState()) ||
+      chargingStation.isInAcceptedState() ||
+      (chargingStation.isInPendingState() &&
+        (params.triggerMessage || messageType === MessageType.CALL_RESULT_MESSAGE))
+    ) {
+      // eslint-disable-next-line @typescript-eslint/no-this-alias
+      const self = this;
+      // Send a message through wsConnection
+      return Utils.promiseWithTimeout(
+        new Promise((resolve, reject) => {
+          const messageToSend = this.buildMessageToSend(
+            chargingStation,
+            messageId,
+            messagePayload,
+            messageType,
+            commandName,
+            responseCallback,
+            errorCallback
+          );
+          if (chargingStation.getEnableStatistics()) {
+            chargingStation.performanceStatistics.addRequestStatistic(commandName, messageType);
+          }
+          // Check if wsConnection opened
+          if (chargingStation.isWebSocketConnectionOpened()) {
+            // Yes: Send Message
+            const beginId = PerformanceStatistics.beginMeasure(commandName);
+            // FIXME: Handle sending error
+            chargingStation.wsConnection.send(messageToSend);
+            PerformanceStatistics.endMeasure(commandName, beginId);
+            logger.debug(
+              `${chargingStation.logPrefix()} >> Command '${commandName}' sent ${this.getMessageTypeString(
+                messageType
+              )} payload: ${messageToSend}`
+            );
+          } else if (!params.skipBufferingOnError) {
+            // Buffer it
+            chargingStation.bufferMessage(messageToSend);
+            const ocppError = new OCPPError(
+              ErrorType.GENERIC_ERROR,
+              `WebSocket closed for buffered message id '${messageId}' with content '${messageToSend}'`,
+              commandName,
+              (messagePayload as JsonObject)?.details ?? {}
+            );
+            if (messageType === MessageType.CALL_MESSAGE) {
+              // Reject it but keep the request in the cache
+              return reject(ocppError);
+            }
+            return errorCallback(ocppError, false);
+          } else {
+            // Reject it
+            return errorCallback(
+              new OCPPError(
+                ErrorType.GENERIC_ERROR,
+                `WebSocket closed for non buffered message id '${messageId}' with content '${messageToSend}'`,
+                commandName,
+                (messagePayload as JsonObject)?.details ?? {}
+              ),
+              false
+            );
+          }
+          // Response?
+          if (messageType !== MessageType.CALL_MESSAGE) {
+            // Yes: send Ok
+            return resolve(messagePayload);
+          }
+
+          /**
+           * Function that will receive the request's response
+           *
+           * @param payload
+           * @param requestPayload
+           */
+          async function responseCallback(
+            payload: JsonType,
+            requestPayload: JsonType
+          ): Promise<void> {
+            if (chargingStation.getEnableStatistics()) {
+              chargingStation.performanceStatistics.addRequestStatistic(
+                commandName,
+                MessageType.CALL_RESULT_MESSAGE
+              );
+            }
+            // Handle the request's response
+            try {
+              await self.ocppResponseService.responseHandler(
+                chargingStation,
+                commandName as RequestCommand,
+                payload,
+                requestPayload
+              );
+              resolve(payload);
+            } catch (error) {
+              reject(error);
+            } finally {
+              chargingStation.requests.delete(messageId);
+            }
+          }
+
+          /**
+           * Function that will receive the request's error response
+           *
+           * @param error
+           * @param requestStatistic
+           */
+          function errorCallback(error: OCPPError, requestStatistic = true): void {
+            if (requestStatistic && chargingStation.getEnableStatistics()) {
+              chargingStation.performanceStatistics.addRequestStatistic(
+                commandName,
+                MessageType.CALL_ERROR_MESSAGE
+              );
+            }
+            logger.error(
+              `${chargingStation.logPrefix()} Error %j occurred when calling command %s with message data %j`,
+              error,
+              commandName,
+              messagePayload
+            );
+            chargingStation.requests.delete(messageId);
+            reject(error);
+          }
+        }),
+        Constants.OCPP_WEBSOCKET_TIMEOUT,
+        new OCPPError(
+          ErrorType.GENERIC_ERROR,
+          `Timeout for message id '${messageId}'`,
+          commandName,
+          (messagePayload as JsonObject)?.details ?? {}
+        ),
+        () => {
+          messageType === MessageType.CALL_MESSAGE && chargingStation.requests.delete(messageId);
+        }
+      );
+    }
+    throw new OCPPError(
+      ErrorType.SECURITY_ERROR,
+      `Cannot send command ${commandName} PDU when the charging station is in ${chargingStation.getRegistrationStatus()} state on the central server`,
+      commandName
+    );
+  }
+
+  private buildMessageToSend(
+    chargingStation: ChargingStation,
+    messageId: string,
+    messagePayload: JsonType | OCPPError,
+    messageType: MessageType,
+    commandName?: RequestCommand | IncomingRequestCommand,
+    responseCallback?: (payload: JsonType, requestPayload: JsonType) => Promise<void>,
+    errorCallback?: (error: OCPPError, requestStatistic?: boolean) => void
+  ): string {
     let messageToSend: string;
     // Type of message
     switch (messageType) {
       // Request
       case MessageType.CALL_MESSAGE:
         // Build request
-        this.chargingStation.requests.set(messageId, [responseCallback, rejectCallback, commandName, commandParams]);
-        messageToSend = JSON.stringify([messageType, messageId, commandName, commandParams]);
+        chargingStation.requests.set(messageId, [
+          responseCallback,
+          errorCallback,
+          commandName,
+          messagePayload as JsonType,
+        ]);
+        messageToSend = JSON.stringify([
+          messageType,
+          messageId,
+          commandName,
+          messagePayload,
+        ] as OutgoingRequest);
         break;
       // Response
       case MessageType.CALL_RESULT_MESSAGE:
         // Build response
-        messageToSend = JSON.stringify([messageType, messageId, commandParams]);
+        messageToSend = JSON.stringify([messageType, messageId, messagePayload] as Response);
         break;
       // Error Message
       case MessageType.CALL_ERROR_MESSAGE:
         // Build Error Message
-        messageToSend = JSON.stringify([messageType, messageId, commandParams?.code ?? ErrorType.GENERIC_ERROR, commandParams?.message ?? '', commandParams?.details ?? {}]);
+        messageToSend = JSON.stringify([
+          messageType,
+          messageId,
+          (messagePayload as OCPPError)?.code ?? ErrorType.GENERIC_ERROR,
+          (messagePayload as OCPPError)?.message ?? '',
+          (messagePayload as OCPPError)?.details ?? { commandName },
+        ] as ErrorResponse);
         break;
     }
     return messageToSend;
   }
 
-  public abstract sendHeartbeat(): Promise<void>;
-  public abstract sendBootNotification(chargePointModel: string, chargePointVendor: string, chargeBoxSerialNumber?: string, firmwareVersion?: string, chargePointSerialNumber?: string, iccid?: string, imsi?: string, meterSerialNumber?: string, meterType?: string): Promise<BootNotificationResponse>;
-  public abstract sendStatusNotification(connectorId: number, status: ChargePointStatus, errorCode?: ChargePointErrorCode): Promise<void>;
-  public abstract sendAuthorize(connectorId: number, idTag?: string): Promise<AuthorizeResponse>;
-  public abstract sendStartTransaction(connectorId: number, idTag?: string): Promise<StartTransactionResponse>;
-  public abstract sendStopTransaction(transactionId: number, meterStop: number, idTag?: string, reason?: StopTransactionReason): Promise<StopTransactionResponse>;
-  public abstract sendMeterValues(connectorId: number, transactionId: number, interval: number): Promise<void>;
-  public abstract sendTransactionBeginMeterValues(connectorId: number, transactionId: number, beginMeterValue: MeterValue): Promise<void>;
-  public abstract sendTransactionEndMeterValues(connectorId: number, transactionId: number, endMeterValue: MeterValue): Promise<void>;
-  public abstract sendDiagnosticsStatusNotification(diagnosticsStatus: DiagnosticsStatus): Promise<void>;
-  public abstract sendError(messageId: string, error: OCPPError, commandName: RequestCommand | IncomingRequestCommand): Promise<unknown>;
+  private getMessageTypeString(messageType: MessageType): string {
+    switch (messageType) {
+      case MessageType.CALL_MESSAGE:
+        return 'request';
+      case MessageType.CALL_RESULT_MESSAGE:
+        return 'response';
+      case MessageType.CALL_ERROR_MESSAGE:
+        return 'error';
+    }
+  }
+
+  private handleRequestError(
+    chargingStation: ChargingStation,
+    commandName: RequestCommand | IncomingRequestCommand,
+    error: Error,
+    params: HandleErrorParams<EmptyObject> = { throwError: true }
+  ): void {
+    logger.error(chargingStation.logPrefix() + ' Request command %s error: %j', commandName, error);
+    if (params?.throwError) {
+      throw error;
+    }
+  }
+
+  // eslint-disable-next-line @typescript-eslint/no-unused-vars
+  public abstract requestHandler<Request extends JsonType, Response extends JsonType>(
+    chargingStation: ChargingStation,
+    commandName: RequestCommand,
+    commandParams?: JsonType,
+    params?: RequestParams
+  ): Promise<Response>;
 }