From: Jérôme Benoit Date: Thu, 25 May 2023 15:59:10 +0000 (+0200) Subject: Merge branch 'main' into reservation-feature X-Git-Tag: v1.2.16~13^2~2 X-Git-Url: https://git.piment-noir.org/?a=commitdiff_plain;h=041936041d9803859ac2faad9316c20e49b658c9;hp=c5e52a07fb5e8e570355415762fc0d97ab4016e0;p=e-mobility-charging-stations-simulator.git Merge branch 'main' into reservation-feature --- diff --git a/README.md b/README.md index a3f0e4e7..9fb599ae 100644 --- 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 diff --git a/src/assets/station-templates/abb-atg.station-template.json b/src/assets/station-templates/abb-atg.station-template.json index 9414a19e..99a67c64 100644 --- a/src/assets/station-templates/abb-atg.station-template.json +++ b/src/assets/station-templates/abb-atg.station-template.json @@ -29,7 +29,7 @@ { "key": "SupportedFeatureProfiles", "readonly": true, - "value": "Core,FirmwareManagement,LocalAuthListManagement,SmartCharging,RemoteTrigger" + "value": "Core,FirmwareManagement,LocalAuthListManagement,SmartCharging,RemoteTrigger,Reservation" }, { "key": "LocalAuthListEnabled", @@ -45,6 +45,11 @@ "key": "WebSocketPingInterval", "readonly": false, "value": "60" + }, + { + "key": "ReserveConnectorZeroSupported", + "readonly": false, + "value": "true" } ] }, diff --git a/src/assets/station-templates/abb.station-template.json b/src/assets/station-templates/abb.station-template.json index 0c6b233a..a95517e7 100644 --- a/src/assets/station-templates/abb.station-template.json +++ b/src/assets/station-templates/abb.station-template.json @@ -29,7 +29,7 @@ { "key": "SupportedFeatureProfiles", "readonly": true, - "value": "Core,FirmwareManagement,LocalAuthListManagement,SmartCharging,RemoteTrigger" + "value": "Core,FirmwareManagement,LocalAuthListManagement,SmartCharging,RemoteTrigger,Reservation" }, { "key": "LocalAuthListEnabled", @@ -45,6 +45,11 @@ "key": "WebSocketPingInterval", "readonly": false, "value": "60" + }, + { + "key": "ReserveConnectorZeroSupported", + "readonly": false, + "value": "false" } ] }, diff --git a/src/assets/station-templates/evlink.station-template.json b/src/assets/station-templates/evlink.station-template.json index fda88bfb..47938eee 100644 --- a/src/assets/station-templates/evlink.station-template.json +++ b/src/assets/station-templates/evlink.station-template.json @@ -30,7 +30,7 @@ { "key": "SupportedFeatureProfiles", "readonly": true, - "value": "Core,FirmwareManagement,LocalAuthListManagement,SmartCharging,RemoteTrigger" + "value": "Core,FirmwareManagement,LocalAuthListManagement,SmartCharging,RemoteTrigger,Reservation" }, { "key": "LocalAuthListEnabled", @@ -46,6 +46,11 @@ "key": "WebSocketPingInterval", "readonly": false, "value": "60" + }, + { + "key": "ReserveConnectorZeroSupported", + "readonly": false, + "value": "true" } ] }, diff --git a/src/assets/station-templates/keba.station-template.json b/src/assets/station-templates/keba.station-template.json index 0d958365..6c0e3437 100644 --- a/src/assets/station-templates/keba.station-template.json +++ b/src/assets/station-templates/keba.station-template.json @@ -27,7 +27,7 @@ { "key": "SupportedFeatureProfiles", "readonly": true, - "value": "Core,FirmwareManagement,LocalAuthListManagement,SmartCharging,RemoteTrigger" + "value": "Core,FirmwareManagement,LocalAuthListManagement,SmartCharging,RemoteTrigger,Reservation" }, { "key": "LocalAuthListEnabled", @@ -43,6 +43,11 @@ "key": "WebSocketPingInterval", "readonly": false, "value": "60" + }, + { + "key": "ReserveConnectorZeroSupported", + "readonly": false, + "value": "false" } ] }, diff --git a/src/assets/station-templates/schneider-imredd.station-template.json b/src/assets/station-templates/schneider-imredd.station-template.json index 0369d0ac..d6cc99f8 100644 --- a/src/assets/station-templates/schneider-imredd.station-template.json +++ b/src/assets/station-templates/schneider-imredd.station-template.json @@ -29,7 +29,7 @@ { "key": "SupportedFeatureProfiles", "readonly": true, - "value": "Core,FirmwareManagement,LocalAuthListManagement,SmartCharging,RemoteTrigger" + "value": "Core,FirmwareManagement,LocalAuthListManagement,SmartCharging,RemoteTrigger,Reservation" }, { "key": "LocalAuthListEnabled", @@ -45,6 +45,11 @@ "key": "WebSocketPingInterval", "readonly": false, "value": "60" + }, + { + "key": "ReserveConnectorZeroSupported", + "readonly": false, + "value": "true" } ] }, diff --git a/src/assets/station-templates/schneider.station-template.json b/src/assets/station-templates/schneider.station-template.json index 4dd71473..8fb25abe 100644 --- a/src/assets/station-templates/schneider.station-template.json +++ b/src/assets/station-templates/schneider.station-template.json @@ -29,7 +29,7 @@ { "key": "SupportedFeatureProfiles", "readonly": true, - "value": "Core,FirmwareManagement,LocalAuthListManagement,SmartCharging,RemoteTrigger" + "value": "Core,FirmwareManagement,LocalAuthListManagement,SmartCharging,RemoteTrigger,Reservation" }, { "key": "LocalAuthListEnabled", @@ -45,6 +45,11 @@ "key": "WebSocketPingInterval", "readonly": false, "value": "60" + }, + { + "key": "ReserveConnectorZeroSupported", + "readonly": false, + "value": "false" } ] }, diff --git a/src/assets/station-templates/siemens.station-template.json b/src/assets/station-templates/siemens.station-template.json index aca529ac..ac075ce6 100644 --- a/src/assets/station-templates/siemens.station-template.json +++ b/src/assets/station-templates/siemens.station-template.json @@ -24,7 +24,7 @@ { "key": "SupportedFeatureProfiles", "readonly": true, - "value": "Core,LocalAuthListManagement" + "value": "Core,LocalAuthListManagement,Reservation" }, { "key": "LocalAuthListEnabled", @@ -40,6 +40,11 @@ "key": "WebSocketPingInterval", "readonly": false, "value": "60" + }, + { + "key": "ReserveConnectorZeroSupported", + "readonly": false, + "value": "true" } ] }, diff --git a/src/assets/station-templates/virtual-simple-atg.station-template.json b/src/assets/station-templates/virtual-simple-atg.station-template.json index 0dbd6782..aedd27cb 100644 --- a/src/assets/station-templates/virtual-simple-atg.station-template.json +++ b/src/assets/station-templates/virtual-simple-atg.station-template.json @@ -24,7 +24,7 @@ { "key": "SupportedFeatureProfiles", "readonly": true, - "value": "Core,FirmwareManagement,LocalAuthListManagement,SmartCharging,RemoteTrigger" + "value": "Core,FirmwareManagement,LocalAuthListManagement,SmartCharging,RemoteTrigger,Reservation" }, { "key": "LocalAuthListEnabled", @@ -40,6 +40,11 @@ "key": "WebSocketPingInterval", "readonly": false, "value": "60" + }, + { + "key": "ReserveConnectorZeroSupported", + "readonly": false, + "value": "false" } ] }, diff --git a/src/assets/station-templates/virtual-simple.station-template.json b/src/assets/station-templates/virtual-simple.station-template.json index d94b5d39..8a0c2801 100644 --- a/src/assets/station-templates/virtual-simple.station-template.json +++ b/src/assets/station-templates/virtual-simple.station-template.json @@ -24,7 +24,7 @@ { "key": "SupportedFeatureProfiles", "readonly": true, - "value": "Core,FirmwareManagement,LocalAuthListManagement,SmartCharging,RemoteTrigger" + "value": "Core,FirmwareManagement,LocalAuthListManagement,SmartCharging,RemoteTrigger,Reservation" }, { "key": "LocalAuthListEnabled", @@ -40,6 +40,11 @@ "key": "WebSocketPingInterval", "readonly": false, "value": "60" + }, + { + "key": "ReserveConnectorZeroSupported", + "readonly": false, + "value": "true" } ] }, diff --git a/src/assets/station-templates/virtual.station-template.json b/src/assets/station-templates/virtual.station-template.json index 3980cba6..962e792f 100644 --- a/src/assets/station-templates/virtual.station-template.json +++ b/src/assets/station-templates/virtual.station-template.json @@ -24,7 +24,7 @@ { "key": "SupportedFeatureProfiles", "readonly": true, - "value": "Core,FirmwareManagement,LocalAuthListManagement,SmartCharging,RemoteTrigger" + "value": "Core,FirmwareManagement,LocalAuthListManagement,SmartCharging,RemoteTrigger,Reservation" }, { "key": "LocalAuthListEnabled", diff --git a/src/charging-station/ChargingStation.ts b/src/charging-station/ChargingStation.ts index 54af77fc..3eab1fc8 100644 --- a/src/charging-station/ChargingStation.ts +++ b/src/charging-station/ChargingStation.ts @@ -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 { + 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 { + 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): [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 { + 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 => { + 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; } diff --git a/src/charging-station/ocpp/1.6/OCPP16IncomingRequestService.ts b/src/charging-station/ocpp/1.6/OCPP16IncomingRequestService.ts index 9e4ff3e0..6917533e 100644 --- a/src/charging-station/ocpp/1.6/OCPP16IncomingRequestService.ts +++ b/src/charging-station/ocpp/1.6/OCPP16IncomingRequestService.ts @@ -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>([ [ @@ -260,6 +274,22 @@ export class OCPP16IncomingRequestService extends OCPPIncomingRequestService { 'constructor' ), ], + [ + OCPP16IncomingRequestCommand.RESERVE_NOW, + OCPP16ServiceUtils.parseJsonSchemaFile( + 'assets/json-schemas/ocpp/1.6/ReserveNow.json', + moduleName, + 'constructor' + ), + ], + [ + OCPP16IncomingRequestCommand.CANCEL_RESERVATION, + OCPP16ServiceUtils.parseJsonSchemaFile( + '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 { 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 { + 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 { + 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 } + ); + } + } } diff --git a/src/charging-station/ocpp/1.6/OCPP16RequestService.ts b/src/charging-station/ocpp/1.6/OCPP16RequestService.ts index 7ff1fdb8..b82599b7 100644 --- a/src/charging-station/ocpp/1.6/OCPP16RequestService.ts +++ b/src/charging-station/ocpp/1.6/OCPP16RequestService.ts @@ -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( + 'assets/json-schemas/ocpp/1.6/ReserveNow.json', + moduleName, + 'constructor' + ), + ], + [ + OCPP16RequestCommand.CANCEL_RESERVATION, + OCPP16ServiceUtils.parseJsonSchemaFile( + 'assets/json-schemas/ocpp/1.6/CancelReservation.json', + moduleName, + 'constructor' + ), + ], ]); this.buildRequestPayload = this.buildRequestPayload.bind(this) as ( chargingStation: ChargingStation, diff --git a/src/charging-station/ocpp/OCPPConstants.ts b/src/charging-station/ocpp/OCPPConstants.ts index 5a271f59..b8448fb6 100644 --- a/src/charging-station/ocpp/OCPPConstants.ts +++ b/src/charging-station/ocpp/OCPPConstants.ts @@ -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 } diff --git a/src/types/ChargingStationTemplate.ts b/src/types/ChargingStationTemplate.ts index e3594d02..a11ac892 100644 --- a/src/types/ChargingStationTemplate.ts +++ b/src/types/ChargingStationTemplate.ts @@ -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; Connectors?: Record; + reservation?: Reservation[]; }; diff --git a/src/types/ocpp/1.6/Requests.ts b/src/types/ocpp/1.6/Requests.ts index 60c1787f..80128a27 100644 --- a/src/types/ocpp/1.6/Requests.ts +++ b/src/types/ocpp/1.6/Requests.ts @@ -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 index 00000000..49bebd14 --- /dev/null +++ b/src/types/ocpp/1.6/Reservation.ts @@ -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', +} diff --git a/src/types/ocpp/1.6/Responses.ts b/src/types/ocpp/1.6/Responses.ts index a6683caa..15c76a71 100644 --- a/src/types/ocpp/1.6/Responses.ts +++ b/src/types/ocpp/1.6/Responses.ts @@ -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 index 00000000..b185b874 --- /dev/null +++ b/src/types/ocpp/Reservation.ts @@ -0,0 +1,3 @@ +import { type OCPP16Reservation } from './1.6/Reservation'; + +export type Reservation = OCPP16Reservation; diff --git a/src/types/ocpp/Responses.ts b/src/types/ocpp/Responses.ts index 24b8b848..a2f431d4 100644 --- a/src/types/ocpp/Responses.ts +++ b/src/types/ocpp/Responses.ts @@ -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, +}; diff --git a/src/utils/Constants.ts b/src/utils/Constants.ts index a5cd8150..bb765c75 100644 --- a/src/utils/Constants.ts +++ b/src/utils/Constants.ts @@ -52,6 +52,8 @@ export class Constants { /* This is intentional */ }); + static readonly DEFAULT_RESERVATION_EXPIRATION_OBSERVATION_INTERVAL = 5000; // Ms + private constructor() { // This is intentional }