Merge branch 'main' into reservation-feature
authorJérôme Benoit <jerome.benoit@sap.com>
Thu, 25 May 2023 15:59:10 +0000 (17:59 +0200)
committerGitHub <noreply@github.com>
Thu, 25 May 2023 15:59:10 +0000 (17:59 +0200)
22 files changed:
README.md
src/assets/station-templates/abb-atg.station-template.json
src/assets/station-templates/abb.station-template.json
src/assets/station-templates/evlink.station-template.json
src/assets/station-templates/keba.station-template.json
src/assets/station-templates/schneider-imredd.station-template.json
src/assets/station-templates/schneider.station-template.json
src/assets/station-templates/siemens.station-template.json
src/assets/station-templates/virtual-simple-atg.station-template.json
src/assets/station-templates/virtual-simple.station-template.json
src/assets/station-templates/virtual.station-template.json
src/charging-station/ChargingStation.ts
src/charging-station/ocpp/1.6/OCPP16IncomingRequestService.ts
src/charging-station/ocpp/1.6/OCPP16RequestService.ts
src/charging-station/ocpp/OCPPConstants.ts
src/types/ChargingStationTemplate.ts
src/types/ocpp/1.6/Requests.ts
src/types/ocpp/1.6/Reservation.ts [new file with mode: 0644]
src/types/ocpp/1.6/Responses.ts
src/types/ocpp/Reservation.ts [new file with mode: 0644]
src/types/ocpp/Responses.ts
src/utils/Constants.ts

index a3f0e4e76a568e1cc063a1ea8096baab535b7568..9fb599ae77d2589ba89b5a3539e8b0977a6238da 100644 (file)
--- a/README.md
+++ b/README.md
@@ -389,8 +389,8 @@ make SUBMODULES_INIT=true
 
 #### Reservation Profile
 
-- :x: CancelReservation
-- :x: ReserveNow
+- :white_check_mark: CancelReservation
+- :white_check_mark: ReserveNow
 
 #### Smart Charging Profile
 
index 9414a19e7bbdfd958049fe517bcef77b92b2e852..99a67c648f0c671fbbe8879b2d4e9ed378c26200 100644 (file)
@@ -29,7 +29,7 @@
       {
         "key": "SupportedFeatureProfiles",
         "readonly": true,
-        "value": "Core,FirmwareManagement,LocalAuthListManagement,SmartCharging,RemoteTrigger"
+        "value": "Core,FirmwareManagement,LocalAuthListManagement,SmartCharging,RemoteTrigger,Reservation"
       },
       {
         "key": "LocalAuthListEnabled",
         "key": "WebSocketPingInterval",
         "readonly": false,
         "value": "60"
+      },
+      {
+        "key": "ReserveConnectorZeroSupported",
+        "readonly": false,
+        "value": "true"
       }
     ]
   },
index 0c6b233adf47329098666c927ef500ccbdbcfa38..a95517e7d67a12b65bfb4fb479a324377eea327d 100644 (file)
@@ -29,7 +29,7 @@
       {
         "key": "SupportedFeatureProfiles",
         "readonly": true,
-        "value": "Core,FirmwareManagement,LocalAuthListManagement,SmartCharging,RemoteTrigger"
+        "value": "Core,FirmwareManagement,LocalAuthListManagement,SmartCharging,RemoteTrigger,Reservation"
       },
       {
         "key": "LocalAuthListEnabled",
         "key": "WebSocketPingInterval",
         "readonly": false,
         "value": "60"
+      },
+      {
+        "key": "ReserveConnectorZeroSupported",
+        "readonly": false,
+        "value": "false"
       }
     ]
   },
index fda88bfb9bfa54b72cafc6d7a088f0d7d3e48cd5..47938eeefc28da96e795a46769c3bc3ad192566d 100644 (file)
@@ -30,7 +30,7 @@
       {
         "key": "SupportedFeatureProfiles",
         "readonly": true,
-        "value": "Core,FirmwareManagement,LocalAuthListManagement,SmartCharging,RemoteTrigger"
+        "value": "Core,FirmwareManagement,LocalAuthListManagement,SmartCharging,RemoteTrigger,Reservation"
       },
       {
         "key": "LocalAuthListEnabled",
         "key": "WebSocketPingInterval",
         "readonly": false,
         "value": "60"
+      },
+      {
+        "key": "ReserveConnectorZeroSupported",
+        "readonly": false,
+        "value": "true"
       }
     ]
   },
index 0d9583658498a668ef31d9c020492e5a0d8797d3..6c0e3437269f4fd3790c4d5d9ddd50a0a7b73487 100644 (file)
@@ -27,7 +27,7 @@
       {
         "key": "SupportedFeatureProfiles",
         "readonly": true,
-        "value": "Core,FirmwareManagement,LocalAuthListManagement,SmartCharging,RemoteTrigger"
+        "value": "Core,FirmwareManagement,LocalAuthListManagement,SmartCharging,RemoteTrigger,Reservation"
       },
       {
         "key": "LocalAuthListEnabled",
         "key": "WebSocketPingInterval",
         "readonly": false,
         "value": "60"
+      },
+      {
+        "key": "ReserveConnectorZeroSupported",
+        "readonly": false,
+        "value": "false"
       }
     ]
   },
index 0369d0ac428cb342e9606cb9695f8a50af519367..d6cc99f8a82d1f095b6f6427ce1dde3947233532 100644 (file)
@@ -29,7 +29,7 @@
       {
         "key": "SupportedFeatureProfiles",
         "readonly": true,
-        "value": "Core,FirmwareManagement,LocalAuthListManagement,SmartCharging,RemoteTrigger"
+        "value": "Core,FirmwareManagement,LocalAuthListManagement,SmartCharging,RemoteTrigger,Reservation"
       },
       {
         "key": "LocalAuthListEnabled",
         "key": "WebSocketPingInterval",
         "readonly": false,
         "value": "60"
+      },
+      {
+        "key": "ReserveConnectorZeroSupported",
+        "readonly": false,
+        "value": "true"
       }
     ]
   },
index 4dd714731bbd4a0fa6bb5e1168f4860c71146750..8fb25abec7f96d5b2e7814f5a9c4690d2a0e475c 100644 (file)
@@ -29,7 +29,7 @@
       {
         "key": "SupportedFeatureProfiles",
         "readonly": true,
-        "value": "Core,FirmwareManagement,LocalAuthListManagement,SmartCharging,RemoteTrigger"
+        "value": "Core,FirmwareManagement,LocalAuthListManagement,SmartCharging,RemoteTrigger,Reservation"
       },
       {
         "key": "LocalAuthListEnabled",
         "key": "WebSocketPingInterval",
         "readonly": false,
         "value": "60"
+      },
+      {
+        "key": "ReserveConnectorZeroSupported",
+        "readonly": false,
+        "value": "false"
       }
     ]
   },
index aca529ace209bf94da67a4a114bd64242f6cd322..ac075ce6f58a77a9fdf5998b6f7dee8d29b2304b 100644 (file)
@@ -24,7 +24,7 @@
       {
         "key": "SupportedFeatureProfiles",
         "readonly": true,
-        "value": "Core,LocalAuthListManagement"
+        "value": "Core,LocalAuthListManagement,Reservation"
       },
       {
         "key": "LocalAuthListEnabled",
         "key": "WebSocketPingInterval",
         "readonly": false,
         "value": "60"
+      },
+      {
+        "key": "ReserveConnectorZeroSupported",
+        "readonly": false,
+        "value": "true"
       }
     ]
   },
index 0dbd6782798ae7c67c0a0ec1a765c07928344e83..aedd27cbbeda051d9ee1d86708ac62d36f75c819 100644 (file)
@@ -24,7 +24,7 @@
       {
         "key": "SupportedFeatureProfiles",
         "readonly": true,
-        "value": "Core,FirmwareManagement,LocalAuthListManagement,SmartCharging,RemoteTrigger"
+        "value": "Core,FirmwareManagement,LocalAuthListManagement,SmartCharging,RemoteTrigger,Reservation"
       },
       {
         "key": "LocalAuthListEnabled",
         "key": "WebSocketPingInterval",
         "readonly": false,
         "value": "60"
+      },
+      {
+        "key": "ReserveConnectorZeroSupported",
+        "readonly": false,
+        "value": "false"
       }
     ]
   },
index d94b5d39991b04e2cb08dd89c1837bd682443d0c..8a0c28011a8f9e0a7bef52754b0bc6843b15aa5f 100644 (file)
@@ -24,7 +24,7 @@
       {
         "key": "SupportedFeatureProfiles",
         "readonly": true,
-        "value": "Core,FirmwareManagement,LocalAuthListManagement,SmartCharging,RemoteTrigger"
+        "value": "Core,FirmwareManagement,LocalAuthListManagement,SmartCharging,RemoteTrigger,Reservation"
       },
       {
         "key": "LocalAuthListEnabled",
         "key": "WebSocketPingInterval",
         "readonly": false,
         "value": "60"
+      },
+      {
+        "key": "ReserveConnectorZeroSupported",
+        "readonly": false,
+        "value": "true"
       }
     ]
   },
index 3980cba66a60734834cbb0781962eb5c46f524c0..962e792fe3ae444f3fc1188cd2cca7e04c1262cf 100644 (file)
@@ -24,7 +24,7 @@
       {
         "key": "SupportedFeatureProfiles",
         "readonly": true,
-        "value": "Core,FirmwareManagement,LocalAuthListManagement,SmartCharging,RemoteTrigger"
+        "value": "Core,FirmwareManagement,LocalAuthListManagement,SmartCharging,RemoteTrigger,Reservation"
       },
       {
         "key": "LocalAuthListEnabled",
index 54af77fc0351bb3d918536c046ecab95c080594b..3eab1fc815956f94ae481f4bc2929206512a53aa 100644 (file)
@@ -26,6 +26,7 @@ import {
   type OCPPRequestService,
   OCPPServiceUtils,
 } from './ocpp';
+import { OCPPConstants } from './ocpp/OCPPConstants';
 import { SharedLRUCache } from './SharedLRUCache';
 import { BaseError, OCPPError } from '../exception';
 import { PerformanceStatistics } from '../performance';
@@ -62,6 +63,11 @@ import {
   MeterValueMeasurand,
   type MeterValuesRequest,
   type MeterValuesResponse,
+  OCPP16AuthorizationStatus,
+  type OCPP16AuthorizeRequest,
+  type OCPP16AuthorizeResponse,
+  OCPP16RequestCommand,
+  OCPP16SupportedFeatureProfiles,
   OCPPVersion,
   type OutgoingRequest,
   PowerUnits,
@@ -81,6 +87,8 @@ import {
   WebSocketCloseEventStatusCode,
   type WsOptions,
 } from '../types';
+import { ReservationTerminationReason } from '../types/ocpp/1.6/Reservation';
+import type { Reservation } from '../types/ocpp/Reservation';
 import {
   ACElectricUtils,
   AsyncLock,
@@ -132,6 +140,8 @@ export class ChargingStation {
   private readonly sharedLRUCache: SharedLRUCache;
   private webSocketPingSetInterval!: NodeJS.Timeout;
   private readonly chargingStationWorkerBroadcastChannel: ChargingStationWorkerBroadcastChannel;
+  private reservations?: Reservation[];
+  private reservationExpiryDateSetInterval?: NodeJS.Timeout;
 
   constructor(index: number, templateFile: string) {
     this.started = false;
@@ -642,6 +652,9 @@ export class ChargingStation {
         if (this.getEnableStatistics() === true) {
           this.performanceStatistics?.start();
         }
+        if (this.supportsReservations()) {
+          this.startReservationExpiryDateSetInterval();
+        }
         this.openWSConnection();
         // Monitor charging station template file
         this.templateFileWatcher = FileUtils.watchJsonFile(
@@ -898,6 +911,211 @@ export class ChargingStation {
     );
   }
 
+  public supportsReservations(): boolean {
+    logger.info(`${this.logPrefix()} Check for reservation support in charging station`);
+    return ChargingStationConfigurationUtils.getConfigurationKey(
+      this,
+      StandardParametersKey.SupportedFeatureProfiles
+    ).value.includes(OCPP16SupportedFeatureProfiles.Reservation);
+  }
+
+  public supportsReservationsOnConnectorId0(): boolean {
+    logger.info(
+      ` ${this.logPrefix()} Check for reservation support on connector 0 in charging station (CS)`
+    );
+    return (
+      this.supportsReservations() &&
+      ChargingStationConfigurationUtils.getConfigurationKey(
+        this,
+        OCPPConstants.OCPP_RESERVE_CONNECTOR_ZERO_SUPPORTED
+      ).value === 'true'
+    );
+  }
+
+  public async addReservation(reservation: Reservation): Promise<void> {
+    if (Utils.isNullOrUndefined(this.reservations)) {
+      this.reservations = [];
+    }
+    const [exists, reservationFound] = this.doesReservationExists(reservation);
+    if (exists) {
+      await this.removeReservation(reservationFound);
+    }
+    this.reservations.push(reservation);
+    if (reservation.connectorId === 0) {
+      return;
+    }
+    this.getConnectorStatus(reservation.connectorId).status = ConnectorStatusEnum.Reserved;
+    await this.ocppRequestService.requestHandler<
+      StatusNotificationRequest,
+      StatusNotificationResponse
+    >(
+      this,
+      RequestCommand.STATUS_NOTIFICATION,
+      OCPPServiceUtils.buildStatusNotificationRequest(
+        this,
+        reservation.connectorId,
+        ConnectorStatusEnum.Reserved
+      )
+    );
+  }
+
+  public async removeReservation(
+    reservation: Reservation,
+    reason?: ReservationTerminationReason
+  ): Promise<void> {
+    const sameReservation = (r: Reservation) => r.id === reservation.id;
+    const index = this.reservations?.findIndex(sameReservation);
+    this.reservations.splice(index, 1);
+    switch (reason) {
+      case ReservationTerminationReason.TRANSACTION_STARTED:
+        // No action needed
+        break;
+      case ReservationTerminationReason.CONNECTOR_STATE_CHANGED:
+        // No action needed
+        break;
+      default: // ReservationTerminationReason.EXPIRED, ReservationTerminationReason.CANCELED
+        this.getConnectorStatus(reservation.connectorId).status = ConnectorStatusEnum.Available;
+        await this.ocppRequestService.requestHandler<
+          StatusNotificationRequest,
+          StatusNotificationResponse
+        >(
+          this,
+          RequestCommand.STATUS_NOTIFICATION,
+          OCPPServiceUtils.buildStatusNotificationRequest(
+            this,
+            reservation.connectorId,
+            ConnectorStatusEnum.Available
+          )
+        );
+        break;
+    }
+  }
+
+  public getReservationById(id: number): Reservation {
+    return this.reservations?.find((reservation) => reservation.id === id);
+  }
+
+  public getReservationByIdTag(id: string): Reservation {
+    return this.reservations?.find((reservation) => reservation.idTag === id);
+  }
+
+  public getReservationByConnectorId(id: number): Reservation {
+    return this.reservations?.find((reservation) => reservation.connectorId === id);
+  }
+
+  public doesReservationExists(reservation: Partial<Reservation>): [boolean, Reservation] {
+    const sameReservation = (r: Reservation) => r.id === reservation.id;
+    const foundReservation = this.reservations?.find(sameReservation);
+    return Utils.isUndefined(foundReservation) ? [false, null] : [true, foundReservation];
+  }
+
+  public async isAuthorized(
+    connectorId: number,
+    idTag: string,
+    parentIdTag?: string
+  ): Promise<boolean> {
+    let authorized = false;
+    const connectorStatus = this.getConnectorStatus(connectorId);
+    if (
+      this.getLocalAuthListEnabled() === true &&
+      this.hasIdTags() === true &&
+      Utils.isNotEmptyString(
+        this.idTagsCache
+          .getIdTags(ChargingStationUtils.getIdTagsFile(this.stationInfo))
+          ?.find((tag) => tag === idTag)
+      )
+    ) {
+      connectorStatus.localAuthorizeIdTag = idTag;
+      connectorStatus.idTagLocalAuthorized = true;
+      authorized = true;
+    } else if (this.getMustAuthorizeAtRemoteStart() === true) {
+      connectorStatus.authorizeIdTag = idTag;
+      const authorizeResponse: OCPP16AuthorizeResponse =
+        await this.ocppRequestService.requestHandler<
+          OCPP16AuthorizeRequest,
+          OCPP16AuthorizeResponse
+        >(this, OCPP16RequestCommand.AUTHORIZE, {
+          idTag: idTag,
+        });
+      if (authorizeResponse?.idTagInfo?.status === OCPP16AuthorizationStatus.ACCEPTED) {
+        authorized = true;
+      }
+    } else {
+      logger.warn(
+        `${this.logPrefix()} The charging station configuration expects authorize at
+          remote start transaction but local authorization or authorize isn't enabled`
+      );
+    }
+    return authorized;
+  }
+
+  public startReservationExpiryDateSetInterval(customInterval?: number): void {
+    const interval =
+      customInterval ?? Constants.DEFAULT_RESERVATION_EXPIRATION_OBSERVATION_INTERVAL;
+    logger.info(
+      `${this.logPrefix()} Reservation expiration date interval is set to ${interval}
+        and starts on CS now`
+    );
+    // eslint-disable-next-line @typescript-eslint/no-misused-promises
+    this.reservationExpiryDateSetInterval = setInterval(async (): Promise<void> => {
+      if (!Utils.isNullOrUndefined(this.reservations) && !Utils.isEmptyArray(this.reservations)) {
+        for (const reservation of this.reservations) {
+          if (reservation.expiryDate.toString() < new Date().toISOString()) {
+            await this.removeReservation(reservation);
+            logger.info(
+              `${this.logPrefix()} Reservation with ID ${
+                reservation.id
+              } reached expiration date and was removed from CS`
+            );
+          }
+        }
+      }
+    }, interval);
+  }
+
+  public restartReservationExpiryDateSetInterval(): void {
+    this.stopReservationExpiryDateSetInterval();
+    this.startReservationExpiryDateSetInterval();
+  }
+
+  public validateIncomingRequestWithReservation(connectorId: number, idTag: string): boolean {
+    const reservation = this.getReservationByConnectorId(connectorId);
+    return Utils.isUndefined(reservation) || reservation.idTag !== idTag;
+  }
+
+  public isConnectorReservable(
+    reservationId: number,
+    connectorId?: number,
+    idTag?: string
+  ): boolean {
+    const [alreadyExists] = this.doesReservationExists({ id: reservationId });
+    if (alreadyExists) {
+      return alreadyExists;
+    }
+    const userReservedAlready = Utils.isUndefined(this.getReservationByIdTag(idTag)) ? false : true;
+    const notConnectorZero = Utils.isUndefined(connectorId) ? true : connectorId > 0;
+    const freeConnectorsAvailable = this.getNumberOfReservableConnectors() > 0;
+    return !alreadyExists && !userReservedAlready && notConnectorZero && freeConnectorsAvailable;
+  }
+
+  private getNumberOfReservableConnectors(): number {
+    let reservableConnectors = 0;
+    this.connectors.forEach((connector, id) => {
+      if (id === 0) {
+        return;
+      }
+      if (connector.status === ConnectorStatusEnum.Available) {
+        reservableConnectors++;
+      }
+    });
+    return reservableConnectors - this.getNumberOfReservationsOnConnectorZero();
+  }
+
+  private getNumberOfReservationsOnConnectorZero(): number {
+    const reservations = this.reservations?.filter((reservation) => reservation.connectorId === 0);
+    return Utils.isNullOrUndefined(reservations) ? 0 : reservations.length;
+  }
+
   private flushMessageBuffer(): void {
     if (this.messageBuffer.size > 0) {
       for (const message of this.messageBuffer.values()) {
@@ -925,6 +1143,12 @@ export class ChargingStation {
     return this.stationInfo.supervisionUrlOcppConfiguration ?? false;
   }
 
+  private stopReservationExpiryDateSetInterval(): void {
+    if (this.reservationExpiryDateSetInterval) {
+      clearInterval(this.reservationExpiryDateSetInterval);
+    }
+  }
+
   private getSupervisionUrlOcppKey(): string {
     return this.stationInfo.supervisionUrlOcppKey ?? VendorParametersKey.ConnectionUrl;
   }
index 9e4ff3e0d9f19b839d5f17d91b92d02a43407838..6917533e529e56d353a247bf4b538780256c68aa 100644 (file)
@@ -24,6 +24,7 @@ import {
   type ClearChargingProfileRequest,
   type ClearChargingProfileResponse,
   type ConnectorStatus,
+  ConnectorStatusEnum,
   ErrorType,
   type GenericResponse,
   GenericStatus,
@@ -35,8 +36,6 @@ import {
   type JsonObject,
   type JsonType,
   OCPP16AuthorizationStatus,
-  type OCPP16AuthorizeRequest,
-  type OCPP16AuthorizeResponse,
   OCPP16AvailabilityType,
   type OCPP16BootNotificationRequest,
   type OCPP16BootNotificationResponse,
@@ -84,7 +83,17 @@ import {
   type UnlockConnectorRequest,
   type UnlockConnectorResponse,
 } from '../../../types';
+import type {
+  OCPP16CancelReservationRequest,
+  OCPP16ReserveNowRequest,
+} from '../../../types/ocpp/1.6/Requests';
+import { ReservationTerminationReason } from '../../../types/ocpp/1.6/Reservation';
+import type {
+  OCPP16CancelReservationResponse,
+  OCPP16ReserveNowResponse,
+} from '../../../types/ocpp/1.6/Responses';
 import { Constants, Utils, logger } from '../../../utils';
+import { OCPPConstants } from '../OCPPConstants';
 import { OCPPIncomingRequestService } from '../OCPPIncomingRequestService';
 
 const moduleName = 'OCPP16IncomingRequestService';
@@ -138,6 +147,11 @@ export class OCPP16IncomingRequestService extends OCPPIncomingRequestService {
       [OCPP16IncomingRequestCommand.TRIGGER_MESSAGE, this.handleRequestTriggerMessage.bind(this)],
       [OCPP16IncomingRequestCommand.DATA_TRANSFER, this.handleRequestDataTransfer.bind(this)],
       [OCPP16IncomingRequestCommand.UPDATE_FIRMWARE, this.handleRequestUpdateFirmware.bind(this)],
+      [OCPP16IncomingRequestCommand.RESERVE_NOW, this.handleRequestReserveNow.bind(this)],
+      [
+        OCPP16IncomingRequestCommand.CANCEL_RESERVATION,
+        this.handleRequestCancelReservation.bind(this),
+      ],
     ]);
     this.jsonSchemas = new Map<OCPP16IncomingRequestCommand, JSONSchemaType<JsonObject>>([
       [
@@ -260,6 +274,22 @@ export class OCPP16IncomingRequestService extends OCPPIncomingRequestService {
           'constructor'
         ),
       ],
+      [
+        OCPP16IncomingRequestCommand.RESERVE_NOW,
+        OCPP16ServiceUtils.parseJsonSchemaFile<OCPP16ReserveNowRequest>(
+          'assets/json-schemas/ocpp/1.6/ReserveNow.json',
+          moduleName,
+          'constructor'
+        ),
+      ],
+      [
+        OCPP16IncomingRequestCommand.CANCEL_RESERVATION,
+        OCPP16ServiceUtils.parseJsonSchemaFile<OCPP16CancelReservationRequest>(
+          'assets/json-schemas/ocpp/1.6/CancelReservation.json',
+          moduleName,
+          'constructor'
+        ),
+      ],
     ]);
     this.validatePayload = this.validatePayload.bind(this) as (
       chargingStation: ChargingStation,
@@ -799,6 +829,18 @@ export class OCPP16IncomingRequestService extends OCPPIncomingRequestService {
     commandPayload: RemoteStartTransactionRequest
   ): Promise<GenericResponse> {
     const transactionConnectorId = commandPayload.connectorId;
+    const reserved =
+      chargingStation.getConnectorStatus(transactionConnectorId).status ===
+      OCPP16ChargePointStatus.Reserved;
+    if (
+      reserved &&
+      chargingStation.validateIncomingRequestWithReservation(
+        transactionConnectorId,
+        commandPayload.idTag
+      )
+    ) {
+      return OCPP16Constants.OCPP_RESPONSE_REJECTED;
+    }
     if (chargingStation.hasConnector(transactionConnectorId) === false) {
       return this.notifyRemoteStartTransactionRejected(
         chargingStation,
@@ -807,8 +849,8 @@ export class OCPP16IncomingRequestService extends OCPPIncomingRequestService {
       );
     }
     if (
-      chargingStation.isChargingStationAvailable() === false ||
-      chargingStation.isConnectorAvailable(transactionConnectorId) === false
+      !chargingStation.isChargingStationAvailable() ||
+      !chargingStation.isConnectorAvailable(transactionConnectorId)
     ) {
       return this.notifyRemoteStartTransactionRejected(
         chargingStation,
@@ -827,36 +869,10 @@ export class OCPP16IncomingRequestService extends OCPPIncomingRequestService {
     const connectorStatus = chargingStation.getConnectorStatus(transactionConnectorId);
     // Check if authorized
     if (chargingStation.getAuthorizeRemoteTxRequests() === true) {
-      let authorized = false;
-      if (
-        chargingStation.getLocalAuthListEnabled() === true &&
-        chargingStation.hasIdTags() === true &&
-        Utils.isNotEmptyString(
-          chargingStation.idTagsCache
-            .getIdTags(ChargingStationUtils.getIdTagsFile(chargingStation.stationInfo))
-            ?.find((idTag) => idTag === commandPayload.idTag)
-        )
-      ) {
-        connectorStatus.localAuthorizeIdTag = commandPayload.idTag;
-        connectorStatus.idTagLocalAuthorized = true;
-        authorized = true;
-      } else if (chargingStation.getMustAuthorizeAtRemoteStart() === true) {
-        connectorStatus.authorizeIdTag = commandPayload.idTag;
-        const authorizeResponse: OCPP16AuthorizeResponse =
-          await chargingStation.ocppRequestService.requestHandler<
-            OCPP16AuthorizeRequest,
-            OCPP16AuthorizeResponse
-          >(chargingStation, OCPP16RequestCommand.AUTHORIZE, {
-            idTag: commandPayload.idTag,
-          });
-        if (authorizeResponse?.idTagInfo?.status === OCPP16AuthorizationStatus.ACCEPTED) {
-          authorized = true;
-        }
-      } else {
-        logger.warn(
-          `${chargingStation.logPrefix()} The charging station configuration expects authorize at remote start transaction but local authorization or authorize isn't enabled`
-        );
-      }
+      const authorized = await chargingStation.isAuthorized(
+        transactionConnectorId,
+        commandPayload.idTag
+      );
       if (authorized === true) {
         // Authorization successful, start transaction
         if (
@@ -867,15 +883,24 @@ export class OCPP16IncomingRequestService extends OCPPIncomingRequestService {
           ) === true
         ) {
           connectorStatus.transactionRemoteStarted = true;
+          const startTransactionPayload: JsonType = {
+            connectorId: transactionConnectorId,
+            idTag: commandPayload.idTag,
+          };
+          if (reserved) {
+            const reservation = chargingStation.getReservationByConnectorId(transactionConnectorId);
+            startTransactionData.reservationId = reservation.id;
+            await chargingStation.removeReservation(
+              reservation,
+              ReservationTerminationReason.TRANSACTION_STARTED
+            );
+          }
           if (
             (
               await chargingStation.ocppRequestService.requestHandler<
                 OCPP16StartTransactionRequest,
                 OCPP16StartTransactionResponse
-              >(chargingStation, OCPP16RequestCommand.START_TRANSACTION, {
-                connectorId: transactionConnectorId,
-                idTag: commandPayload.idTag,
-              })
+              >(chargingStation, OCPP16RequestCommand.START_TRANSACTION, startTransactionData)
             ).idTagInfo.status === OCPP16AuthorizationStatus.ACCEPTED
           ) {
             logger.debug(remoteStartTransactionLogMsg);
@@ -1504,4 +1529,88 @@ export class OCPP16IncomingRequestService extends OCPPIncomingRequestService {
       );
     }
   }
+
+  private async handleRequestReserveNow(
+    chargingStation: ChargingStation,
+    commandPayload: OCPP16ReserveNowRequest
+  ): Promise<OCPP16ReserveNowResponse> {
+    const { reservationId, idTag, connectorId } = commandPayload;
+    let response: OCPP16ReserveNowResponse;
+    try {
+      if (
+        !chargingStation.supportsReservations() &&
+        chargingStation.isConnectorAvailable(connectorId)
+      ) {
+        return OCPPConstants.OCPP_RESERVATION_RESPONSE_REJECTED;
+      }
+      if (connectorId === 0 && !chargingStation.supportsReservationsOnConnectorId0()) {
+        return OCPPConstants.OCPP_RESERVATION_RESPONSE_REJECTED;
+      }
+      if (!(await chargingStation.isAuthorized(connectorId, idTag))) {
+        return OCPPConstants.OCPP_RESERVATION_RESPONSE_REJECTED;
+      }
+      switch (chargingStation.getConnectorStatus(connectorId).status) {
+        case ConnectorStatusEnum.Faulted:
+          response = OCPPConstants.OCPP_RESERVATION_RESPONSE_FAULTED;
+          break;
+        case ConnectorStatusEnum.Occupied:
+          response = OCPPConstants.OCPP_RESERVATION_RESPONSE_OCCUPIED;
+          break;
+        case ConnectorStatusEnum.Unavailable:
+          response = OCPPConstants.OCPP_RESERVATION_RESPONSE_UNAVAILABLE;
+          break;
+        case ConnectorStatusEnum.Reserved:
+          if (!chargingStation.isConnectorReservable(reservationId, connectorId, idTag)) {
+            response = OCPPConstants.OCPP_RESERVATION_RESPONSE_OCCUPIED;
+            break;
+          }
+        // eslint-disable-next-line no-fallthrough
+        default:
+          if (!chargingStation.isConnectorReservable(reservationId)) {
+            response = OCPPConstants.OCPP_RESERVATION_RESPONSE_OCCUPIED;
+            break;
+          }
+          await chargingStation.addReservation({
+            id: commandPayload.reservationId,
+            ...commandPayload,
+          });
+          response = OCPPConstants.OCPP_RESERVATION_RESPONSE_ACCEPTED;
+          break;
+      }
+      return response;
+    } catch (error) {
+      chargingStation.getConnectorStatus(connectorId).status = ConnectorStatusEnum.Available;
+      return this.handleIncomingRequestError(
+        chargingStation,
+        OCPP16IncomingRequestCommand.RESERVE_NOW,
+        error as Error,
+        { errorResponse: OCPPConstants.OCPP_RESERVATION_RESPONSE_FAULTED }
+      );
+    }
+  }
+
+  private async handleRequestCancelReservation(
+    chargingStation: ChargingStation,
+    commandPayload: OCPP16CancelReservationRequest
+  ): Promise<OCPP16CancelReservationResponse> {
+    try {
+      const { reservationId } = commandPayload;
+      const [exists, reservation] = chargingStation.doesReservationExists({ id: reservationId });
+      if (!exists) {
+        logger.error(
+          `${chargingStation.logPrefix()} Reservation with ID ${reservationId} does not exist on charging station`
+        );
+        return OCPPConstants.OCPP_CANCEL_RESERVATION_RESPONSE_REJECTED;
+      }
+      await chargingStation.removeReservation(reservation);
+      return OCPPConstants.OCPP_CANCEL_RESERVATION_RESPONSE_ACCEPTED;
+    } catch (error) {
+      return this.handleIncomingRequestError(
+        chargingStation,
+        OCPP16IncomingRequestCommand.CANCEL_RESERVATION,
+        error as Error,
+        { errorResponse: OCPPConstants.OCPP_CANCEL_RESERVATION_RESPONSE_REJECTED }
+      );
+    }
+  }
 }
index 7ff1fdb8d43b27da48147a789a1612c87644d8a4..b82599b768e08ed13cfff1586ef20d1b12190637 100644 (file)
@@ -24,6 +24,10 @@ import {
   OCPPVersion,
   type RequestParams,
 } from '../../../types';
+import type {
+  OCPP16CancelReservationRequest,
+  OCPP16ReserveNowRequest,
+} from '../../../types/ocpp/1.6/Requests';
 import { Constants, Utils } from '../../../utils';
 import { OCPPRequestService } from '../OCPPRequestService';
 import type { OCPPResponseService } from '../OCPPResponseService';
@@ -119,6 +123,22 @@ export class OCPP16RequestService extends OCPPRequestService {
           'constructor'
         ),
       ],
+      [
+        OCPP16RequestCommand.RESERVE_NOW,
+        OCPP16ServiceUtils.parseJsonSchemaFile<OCPP16ReserveNowRequest>(
+          'assets/json-schemas/ocpp/1.6/ReserveNow.json',
+          moduleName,
+          'constructor'
+        ),
+      ],
+      [
+        OCPP16RequestCommand.CANCEL_RESERVATION,
+        OCPP16ServiceUtils.parseJsonSchemaFile<OCPP16CancelReservationRequest>(
+          'assets/json-schemas/ocpp/1.6/CancelReservation.json',
+          moduleName,
+          'constructor'
+        ),
+      ],
     ]);
     this.buildRequestPayload = this.buildRequestPayload.bind(this) as <Request extends JsonType>(
       chargingStation: ChargingStation,
index 5a271f5902f2a8f36e39d42cbdd407b32be97498..b8448fb63de6539d941646555dfbce427a8b0d30 100644 (file)
@@ -9,6 +9,7 @@ import {
   TriggerMessageStatus,
   UnlockStatus,
 } from '../../types';
+import { CancelReservationStatus, ReservationStatus } from '../../types/ocpp/Responses';
 import { Constants } from '../../utils';
 
 export class OCPPConstants {
@@ -101,6 +102,18 @@ export class OCPPConstants {
     status: DataTransferStatus.REJECTED,
   });
 
+  static readonly OCPP_RESERVATION_RESPONSE_ACCEPTED = Object.freeze({ status: ReservationStatus.ACCEPTED }); // Reservation has been made
+  static readonly OCPP_RESERVATION_RESPONSE_FAULTED = Object.freeze({ status: ReservationStatus.FAULTED }); // Reservation has not been made, because of connector in FAULTED state
+  static readonly OCPP_RESERVATION_RESPONSE_OCCUPIED = Object.freeze({ status: ReservationStatus.OCCUPIED }); // Reservation has not been made, because all connectors are OCCUPIED
+  static readonly OCPP_RESERVATION_RESPONSE_REJECTED = Object.freeze({ status: ReservationStatus.REJECTED }); // Reservation has not been made, because CS is not configured to accept reservations
+  static readonly OCPP_RESERVATION_RESPONSE_UNAVAILABLE = Object.freeze({ status: ReservationStatus.UNAVAILABLE }); // Reservation has not been made, because connectors are spec. connector is in UNAVAILABLE state
+
+  static readonly OCPP_CANCEL_RESERVATION_RESPONSE_ACCEPTED = Object.freeze({ status: CancelReservationStatus.ACCEPTED }); // Reservation for id has been cancelled has been made
+  static readonly OCPP_CANCEL_RESERVATION_RESPONSE_REJECTED = Object.freeze({ status: CancelReservationStatus.REJECTED }); // Reservation could not be cancelled, because there is no reservation active for id
+
+  static readonly OCPP_SUPPORTED_FEATURE_PROFILE_RESERVATION = 'Reservation';
+  static readonly OCPP_RESERVE_CONNECTOR_ZERO_SUPPORTED = 'ReserveConnectorZeroSupported';
+
   protected constructor() {
     // This is intentional
   }
index e3594d02200701ce1502659300e265a8d07278fc..a11ac892779ff535f334f8052a671aa4d17243e1 100644 (file)
@@ -14,6 +14,7 @@ import type {
   MessageTrigger,
   RequestCommand,
 } from './ocpp/Requests';
+import type { Reservation } from './ocpp/Reservation';
 
 export enum CurrentType {
   AC = 'AC',
@@ -115,4 +116,5 @@ export type ChargingStationTemplate = {
   AutomaticTransactionGenerator?: AutomaticTransactionGeneratorConfiguration;
   Evses?: Record<string, EvseTemplate>;
   Connectors?: Record<string, ConnectorStatus>;
+  reservation?: Reservation[];
 };
index 60c1787fefe5e06972a72dfcd5ba730e013f3350..80128a271c9fd6edd3cb6ec9609ad6b2ed898da7 100644 (file)
@@ -21,6 +21,8 @@ export enum OCPP16RequestCommand {
   DIAGNOSTICS_STATUS_NOTIFICATION = 'DiagnosticsStatusNotification',
   FIRMWARE_STATUS_NOTIFICATION = 'FirmwareStatusNotification',
   DATA_TRANSFER = 'DataTransfer',
+  RESERVE_NOW = 'ReserveNow',
+  CANCEL_RESERVATION = 'CancelReservation',
 }
 
 export enum OCPP16IncomingRequestCommand {
@@ -39,6 +41,8 @@ export enum OCPP16IncomingRequestCommand {
   TRIGGER_MESSAGE = 'TriggerMessage',
   DATA_TRANSFER = 'DataTransfer',
   UPDATE_FIRMWARE = 'UpdateFirmware',
+  RESERVE_NOW = 'ReserveNow',
+  CANCEL_RESERVATION = 'CancelReservation',
 }
 
 export type OCPP16HeartbeatRequest = EmptyObject;
@@ -183,3 +187,15 @@ export interface OCPP16DataTransferRequest extends JsonObject {
   messageId?: string;
   data?: string;
 }
+
+export interface OCPP16ReserveNowRequest extends JsonObject {
+  connectorId: number;
+  expiryDate: Date;
+  idTag: string;
+  parentIdTag?: string;
+  reservationId: number;
+}
+
+export interface OCPP16CancelReservationRequest extends JsonObject {
+  reservationId: number;
+}
diff --git a/src/types/ocpp/1.6/Reservation.ts b/src/types/ocpp/1.6/Reservation.ts
new file mode 100644 (file)
index 0000000..49bebd1
--- /dev/null
@@ -0,0 +1,14 @@
+export interface OCPP16Reservation {
+  id: number;
+  connectorId: number;
+  expiryDate: Date;
+  idTag: string;
+  parentIdTag?: string;
+}
+
+export enum ReservationTerminationReason {
+  EXPIRED = 'Expired',
+  TRANSACTION_STARTED = 'TransactionStarted',
+  CONNECTOR_STATE_CHANGED = 'ConnectorStateChanged',
+  CANCELED = 'ReservationCanceled',
+}
index a6683caa87962b9050f6ef9fa1d4e7b8a3b48f2c..15c76a71ee87d9206ec28c46d2720f9076c39be9 100644 (file)
@@ -109,3 +109,24 @@ export interface OCPP16DataTransferResponse extends JsonObject {
   status: OCPP16DataTransferStatus;
   data?: string;
 }
+
+export enum OCPP16CancelReservationStatus {
+  ACCEPTED = 'Accepted',
+  REJECTED = 'Rejected',
+}
+
+export interface OCPP16CancelReservationResponse {
+  status: OCPP16CancelReservationStatus;
+}
+
+export enum OCPP16ReservationStatus {
+  ACCEPTED = 'Accepted',
+  FAULTED = 'Faulted',
+  OCCUPIED = 'Occupied',
+  REJECTED = 'Rejected',
+  UNAVAILABLE = 'Unavailable',
+}
+
+export interface OCPP16ReserveNowResponse {
+  status: OCPP16ReservationStatus;
+}
diff --git a/src/types/ocpp/Reservation.ts b/src/types/ocpp/Reservation.ts
new file mode 100644 (file)
index 0000000..b185b87
--- /dev/null
@@ -0,0 +1,3 @@
+import { type OCPP16Reservation } from './1.6/Reservation';
+
+export type Reservation = OCPP16Reservation;
index 24b8b848bebe9656660c4c884af6faf5a75df932..a2f431d452d602b47875f1ee332ecb10a7748740 100644 (file)
@@ -2,6 +2,7 @@ import type { OCPP16MeterValuesResponse } from './1.6/MeterValues';
 import {
   OCPP16AvailabilityStatus,
   type OCPP16BootNotificationResponse,
+  OCPP16CancelReservationStatus,
   OCPP16ChargingProfileStatus,
   OCPP16ClearChargingProfileStatus,
   OCPP16ConfigurationStatus,
@@ -10,6 +11,7 @@ import {
   type OCPP16DiagnosticsStatusNotificationResponse,
   type OCPP16FirmwareStatusNotificationResponse,
   type OCPP16HeartbeatResponse,
+  OCPP16ReservationStatus,
   type OCPP16StatusNotificationResponse,
   OCPP16TriggerMessageStatus,
   OCPP16UnlockStatus,
@@ -103,3 +105,13 @@ export const DataTransferStatus = {
   ...OCPP16DataTransferStatus,
 } as const;
 export type DataTransferStatus = OCPP16DataTransferStatus;
+
+export type ReservationStatus = OCPP16ReservationStatus;
+export const ReservationStatus = {
+  ...OCPP16ReservationStatus,
+};
+
+export type CancelReservationStatus = OCPP16CancelReservationStatus;
+export const CancelReservationStatus = {
+  ...OCPP16CancelReservationStatus,
+};
index a5cd81502d230e447a96efb4084b66ab59e2af1c..bb765c75dcf8f00d6ed766c1c1f8ccdcd1592357 100644 (file)
@@ -52,6 +52,8 @@ export class Constants {
     /* This is intentional */
   });
 
+  static readonly DEFAULT_RESERVATION_EXPIRATION_OBSERVATION_INTERVAL = 5000; // Ms
+
   private constructor() {
     // This is intentional
   }