From: Jérôme Benoit Date: Tue, 28 Apr 2026 12:06:34 +0000 (+0200) Subject: fix(simulator): add connector Finishing state lifecycle simulation (#1227) (#1812) X-Git-Url: https://git.piment-noir.org/?a=commitdiff_plain;h=3578a8b8f0893208b9d6d2229a49c9721aa67144;p=e-mobility-charging-stations-simulator.git fix(simulator): add connector Finishing state lifecycle simulation (#1227) (#1812) * fix(simulator): add finishingStatusDelay tunable to station template * fix(simulator): delay Available status after transaction end for OCPP 2.0.x * fix(simulator): guard RemoteStartTransaction during Finishing state * test(simulator): add finishing delay tests for OCPP 1.6 and 2.0.x * docs(simulator): document finishingStatusDelay template tunable * [autofix.ci] apply automated fixes * refactor(simulator): audit corrections — rename, DRY, double-send, guard, safety * [autofix.ci] apply automated fixes * fix(simulator): fix JSDoc placement and document early return safety * fix(simulator): address review comments — meter values, tests, docs * fix(simulator): reset connector status before sending Available after delay * fix(simulator): restore original operation ordering in delay=0 path * fix(simulator): add debug log for RemoteStart rejection during Finishing state * fix(simulator): harmonize log messages with existing codebase patterns * refactor(simulator): extract DRY helpers and fix comment in StopTransaction handler * fix(simulator): simplify Finishing guard to check status alone in RemoteStartTransaction * fix(simulator): remove misleading log heuristic in OCPP 2.0 StartTransaction guard * test(simulator): restructure postTransactionDelay tests per ModuleName-Feature naming convention * fix(simulator): resolve Mock type mismatch in OCPP 2.0 postTransactionDelay test * fix(simulator): prevent double power-divider decrement during shutdown with postTransactionDelay * fix(simulator): prevent double-stop during shutdown using spec-compliant guards * fix(simulator): restore resetConnectorStatus after sleep to prevent ATG race condition * fix(simulator): reconcile stale connector state on boot when transactionId is missing * [autofix.ci] apply automated fixes * fix(simulator): also reconcile Finishing state connectors on boot for OCPP 1.6 * fix(simulator): delete transactionId before delay in OCPP 1.6 to prevent duplicate RemoteStop * fix(simulator): use OCPP16ChargePointStatus in version-specific test code * fix(simulator): harmonize log messages with existing codebase format * fix(simulator): make OCPP 1.6 connector unlock unconditional per spec §4.10 --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- diff --git a/README.md b/README.md index afc3c372..cef30888 100644 --- a/README.md +++ b/README.md @@ -198,68 +198,69 @@ But the modifications to test have to be done to the files in the build target d **src/assets/station-templates/\.json**: -| Key | Value(s) | Default Value | Value type | Description | -| ---------------------------------------------------- | ------------- | ---------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| supervisionUrls | | [] | string \| string[] | string or strings array containing connection URIs to OCPP-J servers | -| supervisionUser | | undefined | string | basic HTTP authentication user to OCPP-J server | -| supervisionPassword | | undefined | string | basic HTTP authentication password to OCPP-J server | -| supervisionUrlOcppConfiguration | true/false | false | boolean | enable supervision URL configuration via a vendor OCPP parameter key | -| supervisionUrlOcppKey | | 'ConnectionUrl' | string | the vendor string that will be used as a vendor OCPP parameter key to set the supervision URL | -| autoStart | true/false | true | boolean | enable automatic start of added charging station from template | -| ocppVersion | 1.6/2.0/2.0.1 | 1.6 | string | OCPP version | -| ocppProtocol | json | json | string | OCPP protocol | -| ocppStrictCompliance | true/false | true | boolean | enable strict adherence to the OCPP version and protocol specifications with OCPP commands PDU validation against [OCA](https://www.openchargealliance.org/) JSON schemas | -| ocppPersistentConfiguration | true/false | true | boolean | enable persistent OCPP parameters storage by charging stations 'hashId'. The persistency is ensured by the charging stations configuration files in [dist/assets/configurations](./dist/assets/configurations) | -| stationInfoPersistentConfiguration | true/false | true | boolean | enable persistent station information and specifications storage by charging stations 'hashId'. The persistency is ensured by the charging stations configuration files in [dist/assets/configurations](./dist/assets/configurations) | -| automaticTransactionGeneratorPersistentConfiguration | true/false | true | boolean | enable persistent automatic transaction generator configuration storage by charging stations 'hashId'. The persistency is ensured by the charging stations configuration files in [dist/assets/configurations](./dist/assets/configurations) | -| wsOptions | | {} | ClientOptions & ClientRequestArgs | [ws](https://github.com/websockets/ws) and node.js [http](https://nodejs.org/api/http.html) clients options intersection | -| idTagsFile | | undefined | string | RFID tags list file relative to [src/assets](./src/assets) path | -| iccid | | undefined | string | SIM card ICCID | -| imsi | | undefined | string | SIM card IMSI | -| baseName | | undefined | string | base name to build charging stations id | -| nameSuffix | | undefined | string | name suffix to build charging stations id | -| fixedName | true/false | false | boolean | use the 'baseName' as the charging stations unique name | -| chargePointModel | | undefined | string | charging stations model | -| chargePointVendor | | undefined | string | charging stations vendor | -| chargePointSerialNumberPrefix | | undefined | string | charge point serial number prefix | -| chargeBoxSerialNumberPrefix | | undefined | string | charge box serial number prefix (deprecated since OCPP 1.6) | -| meterSerialNumberPrefix | | undefined | string | meter serial number prefix | -| meterType | | undefined | string | meter type | -| firmwareVersionPattern | | Semantic versioning regular expression: https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string | string | charging stations firmware version pattern | -| firmwareVersion | | undefined | string | charging stations firmware version | -| power | | | float \| float[] | charging stations maximum power value(s) | -| powerSharedByConnectors | true/false | false | boolean | charging stations power shared by its connectors | -| powerUnit | W/kW | W | string | charging stations power unit | -| currentOutType | AC/DC | AC | string | charging stations current out type | -| voltageOut | | AC:230/DC:400 | integer | charging stations voltage out | -| numberOfPhases | 0/1/3 | AC:3/DC:0 | integer | charging stations number of phase(s) | -| numberOfConnectors | | | integer \| integer[] | charging stations number of connector(s) | -| useConnectorId0 | true/false | true | boolean | use connector id 0 definition from the charging station configuration template | -| randomConnectors | true/false | false | boolean | randomize runtime connector id affectation from the connector id definition in charging station configuration template | -| resetTime | | 60 | integer | seconds to wait before the charging stations come back at reset | -| autoRegister | true/false | false | boolean | set charging stations as registered at boot notification for testing purpose | -| autoReconnectMaxRetries | | -1 (unlimited) | integer | connection retries to the OCPP-J server | -| reconnectExponentialDelay | true/false | false | boolean | connection delay retry to the OCPP-J server | -| registrationMaxRetries | | -1 (unlimited) | integer | charging stations boot notification retries | -| amperageLimitationOcppKey | | undefined | string | charging stations OCPP parameter key used to set the amperage limit, per phase for each connector on AC and global for DC | -| amperageLimitationUnit | A/cA/dA/mA | A | string | charging stations amperage limit unit | -| enableStatistics | true/false | false | boolean | enable charging stations statistics | -| remoteAuthorization | true/false | true | boolean | enable RFID tags remote authorization | -| beginEndMeterValues | true/false | false | boolean | enable Transaction.{Begin,End} MeterValues | -| outOfOrderEndMeterValues | true/false | false | boolean | send Transaction.End MeterValues out of order. Need to relax OCPP specifications strict compliance ('ocppStrictCompliance' parameter) | -| meteringPerTransaction | true/false | true | boolean | enable metering history on a per transaction basis | -| transactionDataMeterValues | true/false | false | boolean | enable transaction data MeterValues at stop transaction | -| stopTransactionsOnStopped | true/false | true | boolean | enable stop transactions on charging station stop | -| mainVoltageMeterValues | true/false | true | boolean | include charging stations main voltage MeterValues on three phased charging stations | -| phaseLineToLineVoltageMeterValues | true/false | false | boolean | include charging stations line to line voltage MeterValues on three phased charging stations | -| customValueLimitationMeterValues | true/false | true | boolean | enable limitation on custom fluctuated value in MeterValues | -| firmwareUpgrade | | {
"versionUpgrade": {
"step": 1
},
"reset": true
} | {
versionUpgrade?: {
patternGroup?: number;
step?: number;
};
reset?: boolean;
failureStatus?: 'DownloadFailed' \| 'InstallationFailed';
} | Configuration section for simulating firmware upgrade support. | -| commandsSupport | | {
"incomingCommands": {},
"outgoingCommands": {}
} | {
incomingCommands: Record;
outgoingCommands?: Record;
} | Configuration section for OCPP commands support. Empty section or subsections means all implemented OCPP commands are supported | -| messageTriggerSupport | | {} | Record | Configuration section for OCPP commands trigger support. Empty section means all implemented OCPP trigger commands are supported | -| Configuration | | | ChargingStationOcppConfiguration | charging stations OCPP parameters configuration section | -| AutomaticTransactionGenerator | | | AutomaticTransactionGeneratorConfiguration | charging stations ATG configuration section | -| Connectors | | | Record | charging stations connectors configuration section | -| Evses | | | Record | charging stations EVSEs configuration section | +| Key | Value(s) | Default Value | Value type | Description | +| ---------------------------------------------------- | ------------- | ---------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| supervisionUrls | | [] | string \| string[] | string or strings array containing connection URIs to OCPP-J servers | +| supervisionUser | | undefined | string | basic HTTP authentication user to OCPP-J server | +| supervisionPassword | | undefined | string | basic HTTP authentication password to OCPP-J server | +| supervisionUrlOcppConfiguration | true/false | false | boolean | enable supervision URL configuration via a vendor OCPP parameter key | +| supervisionUrlOcppKey | | 'ConnectionUrl' | string | the vendor string that will be used as a vendor OCPP parameter key to set the supervision URL | +| autoStart | true/false | true | boolean | enable automatic start of added charging station from template | +| ocppVersion | 1.6/2.0/2.0.1 | 1.6 | string | OCPP version | +| ocppProtocol | json | json | string | OCPP protocol | +| ocppStrictCompliance | true/false | true | boolean | enable strict adherence to the OCPP version and protocol specifications with OCPP commands PDU validation against [OCA](https://www.openchargealliance.org/) JSON schemas | +| ocppPersistentConfiguration | true/false | true | boolean | enable persistent OCPP parameters storage by charging stations 'hashId'. The persistency is ensured by the charging stations configuration files in [dist/assets/configurations](./dist/assets/configurations) | +| stationInfoPersistentConfiguration | true/false | true | boolean | enable persistent station information and specifications storage by charging stations 'hashId'. The persistency is ensured by the charging stations configuration files in [dist/assets/configurations](./dist/assets/configurations) | +| automaticTransactionGeneratorPersistentConfiguration | true/false | true | boolean | enable persistent automatic transaction generator configuration storage by charging stations 'hashId'. The persistency is ensured by the charging stations configuration files in [dist/assets/configurations](./dist/assets/configurations) | +| wsOptions | | {} | ClientOptions & ClientRequestArgs | [ws](https://github.com/websockets/ws) and node.js [http](https://nodejs.org/api/http.html) clients options intersection | +| idTagsFile | | undefined | string | RFID tags list file relative to [src/assets](./src/assets) path | +| iccid | | undefined | string | SIM card ICCID | +| imsi | | undefined | string | SIM card IMSI | +| baseName | | undefined | string | base name to build charging stations id | +| nameSuffix | | undefined | string | name suffix to build charging stations id | +| fixedName | true/false | false | boolean | use the 'baseName' as the charging stations unique name | +| chargePointModel | | undefined | string | charging stations model | +| chargePointVendor | | undefined | string | charging stations vendor | +| chargePointSerialNumberPrefix | | undefined | string | charge point serial number prefix | +| chargeBoxSerialNumberPrefix | | undefined | string | charge box serial number prefix (deprecated since OCPP 1.6) | +| meterSerialNumberPrefix | | undefined | string | meter serial number prefix | +| meterType | | undefined | string | meter type | +| firmwareVersionPattern | | Semantic versioning regular expression: https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string | string | charging stations firmware version pattern | +| firmwareVersion | | undefined | string | charging stations firmware version | +| power | | | float \| float[] | charging stations maximum power value(s) | +| powerSharedByConnectors | true/false | false | boolean | charging stations power shared by its connectors | +| powerUnit | W/kW | W | string | charging stations power unit | +| currentOutType | AC/DC | AC | string | charging stations current out type | +| voltageOut | | AC:230/DC:400 | integer | charging stations voltage out | +| numberOfPhases | 0/1/3 | AC:3/DC:0 | integer | charging stations number of phase(s) | +| numberOfConnectors | | | integer \| integer[] | charging stations number of connector(s) | +| useConnectorId0 | true/false | true | boolean | use connector id 0 definition from the charging station configuration template | +| randomConnectors | true/false | false | boolean | randomize runtime connector id affectation from the connector id definition in charging station configuration template | +| resetTime | | 60 | integer | seconds to wait before the charging stations come back at reset | +| autoRegister | true/false | false | boolean | set charging stations as registered at boot notification for testing purpose | +| autoReconnectMaxRetries | | -1 (unlimited) | integer | connection retries to the OCPP-J server | +| reconnectExponentialDelay | true/false | false | boolean | connection delay retry to the OCPP-J server | +| registrationMaxRetries | | -1 (unlimited) | integer | charging stations boot notification retries | +| amperageLimitationOcppKey | | undefined | string | charging stations OCPP parameter key used to set the amperage limit, per phase for each connector on AC and global for DC | +| amperageLimitationUnit | A/cA/dA/mA | A | string | charging stations amperage limit unit | +| enableStatistics | true/false | false | boolean | enable charging stations statistics | +| remoteAuthorization | true/false | true | boolean | enable RFID tags remote authorization | +| beginEndMeterValues | true/false | false | boolean | enable Transaction.{Begin,End} MeterValues | +| outOfOrderEndMeterValues | true/false | false | boolean | send Transaction.End MeterValues out of order. Need to relax OCPP specifications strict compliance ('ocppStrictCompliance' parameter) | +| meteringPerTransaction | true/false | true | boolean | enable metering history on a per transaction basis | +| transactionDataMeterValues | true/false | false | boolean | enable transaction data MeterValues at stop transaction | +| stopTransactionsOnStopped | true/false | true | boolean | enable stop transactions on charging station stop | +| postTransactionDelay | ≥ 0 | 0 | integer | seconds to wait after transaction stop before transitioning connector to Available. Simulates cable-unplug delay. In OCPP 1.6 the connector stays in Finishing state; in OCPP 2.0.x it stays Occupied. 0 = immediate Available (default behavior) | +| mainVoltageMeterValues | true/false | true | boolean | include charging stations main voltage MeterValues on three phased charging stations | +| phaseLineToLineVoltageMeterValues | true/false | false | boolean | include charging stations line to line voltage MeterValues on three phased charging stations | +| customValueLimitationMeterValues | true/false | true | boolean | enable limitation on custom fluctuated value in MeterValues | +| firmwareUpgrade | | {
"versionUpgrade": {
"step": 1
},
"reset": true
} | {
versionUpgrade?: {
patternGroup?: number;
step?: number;
};
reset?: boolean;
failureStatus?: 'DownloadFailed' \| 'InstallationFailed';
} | Configuration section for simulating firmware upgrade support. | +| commandsSupport | | {
"incomingCommands": {},
"outgoingCommands": {}
} | {
incomingCommands: Record;
outgoingCommands?: Record;
} | Configuration section for OCPP commands support. Empty section or subsections means all implemented OCPP commands are supported | +| messageTriggerSupport | | {} | Record | Configuration section for OCPP commands trigger support. Empty section means all implemented OCPP trigger commands are supported | +| Configuration | | | ChargingStationOcppConfiguration | charging stations OCPP parameters configuration section | +| AutomaticTransactionGenerator | | | AutomaticTransactionGeneratorConfiguration | charging stations ATG configuration section | +| Connectors | | | Record | charging stations connectors configuration section | +| Evses | | | Record | charging stations EVSEs configuration section | #### Configuration section syntax example diff --git a/src/charging-station/Helpers.ts b/src/charging-station/Helpers.ts index 568927da..c3c98026 100644 --- a/src/charging-station/Helpers.ts +++ b/src/charging-station/Helpers.ts @@ -506,10 +506,20 @@ export const initializeConnectorsMapStatus = ( ): void => { for (const [connectorId, connectorStatus] of connectors) { if (connectorId > 0 && connectorStatus.transactionStarted === true) { - logger.warn( - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - `${logPrefix} ${moduleName}.initializeConnectorsMapStatus: Connector id ${connectorId.toString()} at initialization has a transaction started with id ${connectorStatus.transactionId?.toString()}` - ) + if ( + connectorStatus.transactionId == null || + connectorStatus.status === ConnectorStatusEnum.Finishing + ) { + resetConnectorStatus(connectorStatus) + connectorStatus.locked = false + logger.warn( + `${logPrefix} ${moduleName}.initializeConnectorsMapStatus: Connector id ${connectorId.toString()} at initialization has stale transaction state, resetting` + ) + } else { + logger.warn( + `${logPrefix} ${moduleName}.initializeConnectorsMapStatus: Connector id ${connectorId.toString()} at initialization has a transaction started with id ${connectorStatus.transactionId.toString()}` + ) + } } if (connectorId === 0) { connectorStatus.availability = AvailabilityType.Operative diff --git a/src/charging-station/ocpp/1.6/OCPP16IncomingRequestService.ts b/src/charging-station/ocpp/1.6/OCPP16IncomingRequestService.ts index 329fd3e1..090458b7 100644 --- a/src/charging-station/ocpp/1.6/OCPP16IncomingRequestService.ts +++ b/src/charging-station/ocpp/1.6/OCPP16IncomingRequestService.ts @@ -1301,6 +1301,16 @@ export class OCPP16IncomingRequestService extends OCPPIncomingRequestService { idTag ) } + if ( + chargingStation.getConnectorStatus(transactionConnectorId)?.status === + OCPP16ChargePointStatus.Finishing + ) { + return this.notifyRemoteStartTransactionRejected( + chargingStation, + transactionConnectorId, + idTag + ) + } if ( !chargingStation.isChargingStationAvailable() || !chargingStation.isConnectorAvailable(transactionConnectorId) diff --git a/src/charging-station/ocpp/1.6/OCPP16ResponseService.ts b/src/charging-station/ocpp/1.6/OCPP16ResponseService.ts index 444c5124..a2a5d1a9 100644 --- a/src/charging-station/ocpp/1.6/OCPP16ResponseService.ts +++ b/src/charging-station/ocpp/1.6/OCPP16ResponseService.ts @@ -13,6 +13,7 @@ import { } from '../../../charging-station/index.js' import { ChargingStationEvents, + type ConnectorStatus, type JsonType, OCPP16AuthorizationStatus, type OCPP16AuthorizeRequest, @@ -34,10 +35,11 @@ import { ReservationTerminationReason, type ResponseHandler, } from '../../../types/index.js' -import { Constants, convertToInt, logger, truncateId } from '../../../utils/index.js' +import { Constants, convertToInt, logger, sleep, truncateId } from '../../../utils/index.js' import { restoreConnectorStatus, sendAndSetConnectorStatus, + sendPostTransactionStatus, } from '../OCPPConnectorStatusOperations.js' import { OCPPResponseService } from '../OCPPResponseService.js' import { createPayloadValidatorMap, isRequestCommandSupported } from '../OCPPServiceUtils.js' @@ -45,6 +47,32 @@ import { OCPP16ServiceUtils } from './OCPP16ServiceUtils.js' const moduleName = 'OCPP16ResponseService' +const decrementPowerDivider = (chargingStation: ChargingStation): void => { + if (chargingStation.stationInfo?.powerSharedByConnectors === true) { + if (chargingStation.powerDivider != null && chargingStation.powerDivider > 0) { + --chargingStation.powerDivider + } else { + logger.error( + `${chargingStation.logPrefix()} ${moduleName}.handleResponseStopTransaction: powerDivider is ${ + chargingStation.powerDivider?.toString() ?? 'undefined' + }, cannot decrement` + ) + } + } +} + +const finalizeTransactionConnectorStatus = ( + connectorStatus: ConnectorStatus | undefined, + requestPayload: OCPP16StopTransactionRequest +): string | undefined => { + const transactionIdTag = requestPayload.idTag ?? connectorStatus?.transactionIdTag + resetConnectorStatus(connectorStatus) + if (connectorStatus != null) { + connectorStatus.locked = false + } + return transactionIdTag +} + /** * OCPP 1.6 Response Service - handles and processes all outgoing request responses * from the Charging Station (CP) to the Central System (CS) using OCPP 1.6 protocol. @@ -507,41 +535,41 @@ export class OCPP16ResponseService extends OCPPResponseService { ], transactionId: requestPayload.transactionId, })) - if ( - !chargingStation.isChargingStationAvailable() || - !chargingStation.isConnectorAvailable(transactionConnectorId) - ) { - await sendAndSetConnectorStatus(chargingStation, { - connectorId: transactionConnectorId, - status: OCPP16ChargePointStatus.Unavailable, - } as OCPP16StatusNotificationRequest) - } else { - await sendAndSetConnectorStatus(chargingStation, { - connectorId: transactionConnectorId, - status: OCPP16ChargePointStatus.Available, - } as OCPP16StatusNotificationRequest) - } - if (chargingStation.stationInfo?.powerSharedByConnectors === true) { - if (chargingStation.powerDivider != null && chargingStation.powerDivider > 0) { - --chargingStation.powerDivider - } else { - logger.error( - `${chargingStation.logPrefix()} ${moduleName}.handleResponseStopTransaction: powerDivider is ${ - chargingStation.powerDivider?.toString() ?? 'undefined' - }, cannot decrement` - ) + const postTransactionDelay = chargingStation.stationInfo?.postTransactionDelay ?? 0 + let transactionIdTag: string | undefined + if (postTransactionDelay > 0) { + decrementPowerDivider(chargingStation) + // Send Finishing status if not already set (idempotency guard) + const transactionConnectorStatus = chargingStation.getConnectorStatus(transactionConnectorId) + if (transactionConnectorStatus?.status !== OCPP16ChargePointStatus.Finishing) { + await sendAndSetConnectorStatus(chargingStation, { + connectorId: transactionConnectorId, + status: OCPP16ChargePointStatus.Finishing, + } as OCPP16StatusNotificationRequest) } + OCPP16ServiceUtils.stopUpdatedMeterValues(chargingStation, transactionConnectorId) + if (transactionConnectorStatus != null) { + delete transactionConnectorStatus.transactionId + } + await sleep(secondsToMilliseconds(postTransactionDelay)) + if (!chargingStation.started) { + return + } + transactionIdTag = finalizeTransactionConnectorStatus( + transactionConnectorStatus, + requestPayload + ) + await sendPostTransactionStatus(chargingStation, transactionConnectorId) + } else { + await sendPostTransactionStatus(chargingStation, transactionConnectorId) + decrementPowerDivider(chargingStation) + const transactionConnectorStatus = chargingStation.getConnectorStatus(transactionConnectorId) + transactionIdTag = finalizeTransactionConnectorStatus( + transactionConnectorStatus, + requestPayload + ) + OCPP16ServiceUtils.stopUpdatedMeterValues(chargingStation, transactionConnectorId) } - const transactionConnectorStatus = chargingStation.getConnectorStatus(transactionConnectorId) - const transactionIdTag = requestPayload.idTag ?? transactionConnectorStatus?.transactionIdTag - resetConnectorStatus(transactionConnectorStatus) - if ( - transactionConnectorStatus != null && - (payload.idTagInfo == null || payload.idTagInfo.status === OCPP16AuthorizationStatus.ACCEPTED) - ) { - transactionConnectorStatus.locked = false - } - OCPP16ServiceUtils.stopUpdatedMeterValues(chargingStation, transactionConnectorId) const logMsg = `${chargingStation.logPrefix()} ${moduleName}.handleResponseStopTransaction: Transaction with id ${requestPayload.transactionId.toString()} STOPPED on ${ // eslint-disable-next-line @typescript-eslint/restrict-template-expressions chargingStation.stationInfo?.chargingStationId diff --git a/src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.ts b/src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.ts index c92b8460..b37ec067 100644 --- a/src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.ts +++ b/src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.ts @@ -2508,7 +2508,8 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService { if ( connectorStatus.transactionStarted === true || - connectorStatus.transactionPending === true + connectorStatus.transactionPending === true || + connectorStatus.locked === true ) { logger.warn( `${chargingStation.logPrefix()} ${moduleName}.handleRequestStartTransaction: Connector ${connectorId.toString()} already has an active or pending transaction` diff --git a/src/charging-station/ocpp/2.0/OCPP20ServiceUtils.ts b/src/charging-station/ocpp/2.0/OCPP20ServiceUtils.ts index 66360c3e..a5f6a4c2 100644 --- a/src/charging-station/ocpp/2.0/OCPP20ServiceUtils.ts +++ b/src/charging-station/ocpp/2.0/OCPP20ServiceUtils.ts @@ -4,7 +4,7 @@ import { type ChargingStation, resetConnectorStatus } from '../../../charging-st import { OCPPError } from '../../../exception/index.js' import { type ConnectorStatus, - ConnectorStatusEnum, + type ConnectorStatusEnum, ErrorType, type MeterValue, OCPP20AuthorizationStatusEnumType, @@ -49,6 +49,7 @@ import { generateUUID, isNotEmptyArray, logger, + sleep, validateIdentifierString, } from '../../../utils/index.js' import { buildConfigKey, getConfigurationKey } from '../../index.js' @@ -57,7 +58,7 @@ import { mapOCPP20TokenType, OCPPAuthServiceFactory, } from '../auth/index.js' -import { sendAndSetConnectorStatus } from '../OCPPConnectorStatusOperations.js' +import { sendPostTransactionStatus } from '../OCPPConnectorStatusOperations.js' import { buildMeterValue, createPayloadConfigs, @@ -199,17 +200,17 @@ export class OCPP20ServiceUtils { return } OCPP20ServiceUtils.stopUpdatedMeterValues(chargingStation, connectorId) + const postTransactionDelay = chargingStation.stationInfo?.postTransactionDelay ?? 0 + if (postTransactionDelay > 0) { + delete connectorStatus.transactionId + await sleep(secondsToMilliseconds(postTransactionDelay)) + if (!chargingStation.started) { + return + } + } resetConnectorStatus(connectorStatus) connectorStatus.locked = false - const targetStatus = - chargingStation.isChargingStationAvailable() && - chargingStation.isConnectorAvailable(connectorId) - ? ConnectorStatusEnum.Available - : ConnectorStatusEnum.Unavailable - await sendAndSetConnectorStatus(chargingStation, { - connectorId, - connectorStatus: targetStatus, - } as unknown as OCPP20StatusNotificationRequest) + await sendPostTransactionStatus(chargingStation, connectorId) } /** diff --git a/src/charging-station/ocpp/OCPPConnectorStatusOperations.ts b/src/charging-station/ocpp/OCPPConnectorStatusOperations.ts index d07e2809..c221a63f 100644 --- a/src/charging-station/ocpp/OCPPConnectorStatusOperations.ts +++ b/src/charging-station/ocpp/OCPPConnectorStatusOperations.ts @@ -48,6 +48,28 @@ export const sendAndSetConnectorStatus = async ( }) } +/** + * Sends Available or Unavailable connector status after a transaction ends. + * Re-evaluates station and connector availability to determine the target status. + * @param chargingStation - Target charging station + * @param connectorId - Connector ID to transition + */ +export const sendPostTransactionStatus = async ( + chargingStation: ChargingStation, + connectorId: number +): Promise => { + const status = + chargingStation.isChargingStationAvailable() && + chargingStation.isConnectorAvailable(connectorId) + ? ConnectorStatusEnum.Available + : ConnectorStatusEnum.Unavailable + await sendAndSetConnectorStatus(chargingStation, { + connectorId, + connectorStatus: status, + status, + } as unknown as StatusNotificationRequest) +} + /** * Restores a connector status to Reserved or Available based on its current state. * @param chargingStation - Target charging station diff --git a/src/charging-station/ocpp/OCPPServiceOperations.ts b/src/charging-station/ocpp/OCPPServiceOperations.ts index a2afb70d..44ffb050 100644 --- a/src/charging-station/ocpp/OCPPServiceOperations.ts +++ b/src/charging-station/ocpp/OCPPServiceOperations.ts @@ -4,6 +4,7 @@ import { type ChargingStation } from '../../charging-station/index.js' import { OCPPError } from '../../exception/index.js' import { AuthorizationStatus, + ConnectorStatusEnum, ErrorType, OCPPVersion, type StartTransactionResult, @@ -101,7 +102,10 @@ export const stopRunningTransactions = async ( switch (chargingStation.stationInfo?.ocppVersion) { case OCPPVersion.VERSION_16: { for (const { connectorId, connectorStatus } of chargingStation.iterateConnectors(true)) { - if (connectorStatus.transactionStarted === true) { + if ( + connectorStatus.transactionStarted === true && + connectorStatus.status !== ConnectorStatusEnum.Finishing + ) { await OCPP16ServiceUtils.stopTransactionOnConnector(chargingStation, connectorId, reason) } } diff --git a/src/types/ChargingStationTemplate.ts b/src/types/ChargingStationTemplate.ts index c17544f7..f88f24da 100644 --- a/src/types/ChargingStationTemplate.ts +++ b/src/types/ChargingStationTemplate.ts @@ -94,6 +94,8 @@ export interface ChargingStationTemplate { /** @deprecated Replaced by ocppStrictCompliance. */ payloadSchemaValidation?: boolean phaseLineToLineVoltageMeterValues?: boolean + /** Seconds to wait after transaction stop before transitioning connector to Available. Simulates cable-unplug delay. In OCPP 1.6 the connector stays in Finishing state; in OCPP 2.0.x it stays Occupied. 0 = immediate Available (default behavior). */ + postTransactionDelay?: number power?: number | number[] powerSharedByConnectors?: boolean powerUnit?: PowerUnits diff --git a/src/utils/Constants.ts b/src/utils/Constants.ts index 5953ec63..837b74eb 100644 --- a/src/utils/Constants.ts +++ b/src/utils/Constants.ts @@ -80,6 +80,7 @@ export class Constants { ocppVersion: OCPPVersion.VERSION_16, outOfOrderEndMeterValues: false, phaseLineToLineVoltageMeterValues: false, + postTransactionDelay: 0, reconnectExponentialDelay: false, registrationMaxRetries: -1, remoteAuthorization: true, diff --git a/tests/charging-station/ocpp/1.6/OCPP16IncomingRequestService-RemoteStartTransaction.test.ts b/tests/charging-station/ocpp/1.6/OCPP16IncomingRequestService-RemoteStartTransaction.test.ts index 2010ee0d..664dd19d 100644 --- a/tests/charging-station/ocpp/1.6/OCPP16IncomingRequestService-RemoteStartTransaction.test.ts +++ b/tests/charging-station/ocpp/1.6/OCPP16IncomingRequestService-RemoteStartTransaction.test.ts @@ -15,6 +15,7 @@ import { OCPP16IncomingRequestService } from '../../../../src/charging-station/o import { AvailabilityType, GenericStatus, + OCPP16ChargePointStatus, OCPP16IncomingRequestCommand, OCPP16RequestCommand, } from '../../../../src/types/index.js' @@ -226,6 +227,27 @@ await describe('OCPP16IncomingRequestService — RemoteStartTransaction', async assert.strictEqual(response.status, GenericStatus.Rejected) }) + // --- Finishing state guard --- + + await it('should reject remote start transaction when connector is in Finishing state', async () => { + // Arrange + const { station, testableService } = testContext + const connectorStatus = station.getConnectorStatus(1) + if (connectorStatus != null) { + connectorStatus.status = OCPP16ChargePointStatus.Finishing + } + const request: RemoteStartTransactionRequest = { + connectorId: 1, + idTag: TEST_ID_TAG, + } + + // Act + const response = await testableService.handleRequestRemoteStartTransaction(station, request) + + // Assert + assert.strictEqual(response.status, GenericStatus.Rejected) + }) + // --- Event listeners --- await describe('REMOTE_START_TRANSACTION event listener', async () => { diff --git a/tests/charging-station/ocpp/1.6/OCPP16ResponseService-PostTransactionDelay.test.ts b/tests/charging-station/ocpp/1.6/OCPP16ResponseService-PostTransactionDelay.test.ts new file mode 100644 index 00000000..d9e00193 --- /dev/null +++ b/tests/charging-station/ocpp/1.6/OCPP16ResponseService-PostTransactionDelay.test.ts @@ -0,0 +1,260 @@ +/** + * @file Tests for OCPP16ResponseService postTransactionDelay + * @description Verifies the postTransactionDelay feature in OCPP 1.6 StopTransaction response + * handling: Finishing→Available transitions, zero-delay immediate transitions, + * availability re-evaluation, and shutdown-during-delay safety. + */ + +import assert from 'node:assert/strict' +import { afterEach, beforeEach, describe, it, mock } from 'node:test' + +import type { ChargingStation } from '../../../../src/charging-station/index.js' +import type { OCPP16ResponseService } from '../../../../src/charging-station/ocpp/1.6/OCPP16ResponseService.js' +import type { + OCPP16StopTransactionRequest, + OCPP16StopTransactionResponse, +} from '../../../../src/types/index.js' + +import { OCPP16ServiceUtils } from '../../../../src/charging-station/ocpp/1.6/OCPP16ServiceUtils.js' +import { + AvailabilityType, + OCPP16AuthorizationStatus, + OCPP16ChargePointStatus, + OCPP16RequestCommand, +} from '../../../../src/types/index.js' +import { + flushMicrotasks, + setupConnectorWithTransaction, + standardCleanup, + withMockTimers, +} from '../../../helpers/TestLifecycleHelpers.js' +import { createOCPP16ResponseTestContext, setMockRequestHandler } from './OCPP16TestUtils.js' + +await describe('OCPP16ResponseService — PostTransactionDelay', async () => { + let station: ChargingStation + let responseService: OCPP16ResponseService + let requestCalls: unknown[][] + + beforeEach(() => { + const ctx = createOCPP16ResponseTestContext({ + stationInfo: { postTransactionDelay: 5 }, + }) + station = ctx.station + responseService = ctx.responseService + station.started = true + + requestCalls = [] + setMockRequestHandler(station, (...args: unknown[]) => { + requestCalls.push(args) + return Promise.resolve({}) + }) + + mock.method(OCPP16ServiceUtils, 'startUpdatedMeterValues', () => { + /* noop */ + }) + mock.method(OCPP16ServiceUtils, 'stopUpdatedMeterValues', () => { + /* noop */ + }) + }) + + afterEach(() => { + standardCleanup() + }) + + await it('should send Finishing then Available after configured delay', async t => { + // Arrange + setupConnectorWithTransaction(station, 1, { transactionId: 100 }) + const requestPayload: OCPP16StopTransactionRequest = { + meterStop: 1000, + timestamp: new Date(), + transactionId: 100, + } + const responsePayload: OCPP16StopTransactionResponse = { + idTagInfo: { status: OCPP16AuthorizationStatus.ACCEPTED }, + } + + // Act + await withMockTimers(t, ['setTimeout'], async () => { + const promise = responseService.responseHandler( + station, + OCPP16RequestCommand.STOP_TRANSACTION, + responsePayload, + requestPayload + ) + for (let i = 0; i < 10; i++) { + await flushMicrotasks() + } + t.mock.timers.tick(5000) + for (let i = 0; i < 10; i++) { + await flushMicrotasks() + } + await promise + }) + + // Assert + const statusCalls = requestCalls.filter( + call => + call[1] === OCPP16RequestCommand.STATUS_NOTIFICATION && + (call[2] as Record).connectorId === 1 + ) + assert.ok( + statusCalls.length >= 2, + `Expected at least 2 status calls, got ${String(statusCalls.length)}` + ) + assert.strictEqual( + (statusCalls[0][2] as Record).status, + OCPP16ChargePointStatus.Finishing + ) + assert.strictEqual( + (statusCalls[1][2] as Record).status, + OCPP16ChargePointStatus.Available + ) + }) + + await it('should send Available immediately when postTransactionDelay is 0', async () => { + // Arrange + assert.ok(station.stationInfo != null, 'stationInfo should be defined') + station.stationInfo.postTransactionDelay = 0 + setupConnectorWithTransaction(station, 1, { transactionId: 200 }) + const requestPayload: OCPP16StopTransactionRequest = { + meterStop: 2000, + timestamp: new Date(), + transactionId: 200, + } + const responsePayload: OCPP16StopTransactionResponse = { + idTagInfo: { status: OCPP16AuthorizationStatus.ACCEPTED }, + } + + // Act + await responseService.responseHandler( + station, + OCPP16RequestCommand.STOP_TRANSACTION, + responsePayload, + requestPayload + ) + + // Assert + const statusCalls = requestCalls.filter( + call => + call[1] === OCPP16RequestCommand.STATUS_NOTIFICATION && + (call[2] as Record).connectorId === 1 + ) + const finishingCalls = statusCalls.filter( + call => (call[2] as Record).status === OCPP16ChargePointStatus.Finishing + ) + assert.strictEqual( + finishingCalls.length, + 0, + 'No Finishing status should be sent when delay is 0' + ) + const availableCalls = statusCalls.filter( + call => (call[2] as Record).status === OCPP16ChargePointStatus.Available + ) + assert.ok(availableCalls.length >= 1, 'Should send Available status') + }) + + await it('should send Unavailable after delay when station becomes unavailable during finishing', async t => { + // Arrange + setupConnectorWithTransaction(station, 1, { transactionId: 300 }) + const connector0 = station.getConnectorStatus(0) + if (connector0 != null) { + connector0.availability = AvailabilityType.Inoperative + } + + const requestPayload: OCPP16StopTransactionRequest = { + meterStop: 3000, + timestamp: new Date(), + transactionId: 300, + } + const responsePayload: OCPP16StopTransactionResponse = { + idTagInfo: { status: OCPP16AuthorizationStatus.ACCEPTED }, + } + + // Act + await withMockTimers(t, ['setTimeout'], async () => { + const promise = responseService.responseHandler( + station, + OCPP16RequestCommand.STOP_TRANSACTION, + responsePayload, + requestPayload + ) + for (let i = 0; i < 10; i++) { + await flushMicrotasks() + } + t.mock.timers.tick(5000) + for (let i = 0; i < 10; i++) { + await flushMicrotasks() + } + await promise + }) + + // Assert + const statusCalls = requestCalls.filter( + call => + call[1] === OCPP16RequestCommand.STATUS_NOTIFICATION && + (call[2] as Record).connectorId === 1 + ) + assert.ok( + statusCalls.length >= 2, + `Expected at least 2 status calls, got ${String(statusCalls.length)}` + ) + assert.strictEqual( + (statusCalls[0][2] as Record).status, + OCPP16ChargePointStatus.Finishing + ) + assert.strictEqual( + (statusCalls[1][2] as Record).status, + OCPP16ChargePointStatus.Unavailable + ) + }) + + await it('should skip cleanup when station stops during delay', async t => { + // Arrange + setupConnectorWithTransaction(station, 1, { transactionId: 400 }) + const requestPayload: OCPP16StopTransactionRequest = { + meterStop: 4000, + timestamp: new Date(), + transactionId: 400, + } + const responsePayload: OCPP16StopTransactionResponse = { + idTagInfo: { status: OCPP16AuthorizationStatus.ACCEPTED }, + } + + // Act + await withMockTimers(t, ['setTimeout'], async () => { + const promise = responseService.responseHandler( + station, + OCPP16RequestCommand.STOP_TRANSACTION, + responsePayload, + requestPayload + ) + for (let i = 0; i < 10; i++) { + await flushMicrotasks() + } + station.started = false + t.mock.timers.tick(5000) + for (let i = 0; i < 10; i++) { + await flushMicrotasks() + } + await promise + }) + + // Assert + const statusCalls = requestCalls.filter( + call => + call[1] === OCPP16RequestCommand.STATUS_NOTIFICATION && + (call[2] as Record).connectorId === 1 + ) + assert.strictEqual(statusCalls.length, 1, 'Only Finishing status should be sent') + assert.strictEqual( + (statusCalls[0][2] as Record).status, + OCPP16ChargePointStatus.Finishing + ) + const connectorStatus = station.getConnectorStatus(1) + if (connectorStatus == null) { + assert.fail('Expected connector 1 to exist') + } + assert.strictEqual(connectorStatus.transactionStarted, true) + assert.strictEqual(connectorStatus.transactionId, undefined) + }) +}) diff --git a/tests/charging-station/ocpp/2.0/OCPP20ServiceUtils-PostTransactionDelay.test.ts b/tests/charging-station/ocpp/2.0/OCPP20ServiceUtils-PostTransactionDelay.test.ts new file mode 100644 index 00000000..81d226cb --- /dev/null +++ b/tests/charging-station/ocpp/2.0/OCPP20ServiceUtils-PostTransactionDelay.test.ts @@ -0,0 +1,119 @@ +/** + * @file Tests for OCPP20ServiceUtils postTransactionDelay + * @description Verifies the postTransactionDelay feature in OCPP 2.0.x cleanupEndedTransaction: + * delayed Available transitions, zero-delay immediate transitions, + * and shutdown-during-delay safety. + */ + +import assert from 'node:assert/strict' +import { afterEach, beforeEach, describe, it, mock } from 'node:test' + +import type { ChargingStation } from '../../../../src/charging-station/index.js' +import type { ConnectorStatus } from '../../../../src/types/index.js' + +import { OCPP20ServiceUtils } from '../../../../src/charging-station/ocpp/2.0/OCPP20ServiceUtils.js' +import { OCPPVersion } from '../../../../src/types/index.js' +import { + flushMicrotasks, + standardCleanup, + withMockTimers, +} from '../../../helpers/TestLifecycleHelpers.js' +import { createMockChargingStation } from '../../helpers/StationHelpers.js' + +await describe('OCPP20ServiceUtils — PostTransactionDelay', async () => { + let station: ChargingStation + let connectorStatus: ConnectorStatus + let requestHandlerMock: ReturnType + + beforeEach(() => { + const requestHandler = mock.fn(async () => Promise.resolve({})) + requestHandlerMock = requestHandler + const result = createMockChargingStation({ + connectorsCount: 1, + ocppRequestService: { + requestHandler: requestHandler as (...args: unknown[]) => Promise, + }, + ocppVersion: OCPPVersion.VERSION_20, + started: true, + stationInfo: { + ocppVersion: OCPPVersion.VERSION_20, + postTransactionDelay: 3, + }, + }) + station = result.station + const cs = station.getConnectorStatus(1) + if (cs == null) { + throw new Error('Expected connector 1 to exist') + } + connectorStatus = cs + connectorStatus.transactionStarted = true + connectorStatus.transactionId = 'tx-1' + connectorStatus.locked = true + }) + + afterEach(() => { + standardCleanup() + }) + + await it('should delay Available transition after transaction end', async t => { + // Act + await withMockTimers(t, ['setTimeout'], async () => { + const promise = OCPP20ServiceUtils.cleanupEndedTransaction(station, 1, connectorStatus) + for (let i = 0; i < 10; i++) { + await flushMicrotasks() + } + t.mock.timers.tick(3000) + for (let i = 0; i < 10; i++) { + await flushMicrotasks() + } + await promise + }) + + // Assert + assert.strictEqual(connectorStatus.transactionStarted, false) + assert.strictEqual(connectorStatus.transactionId, undefined) + assert.strictEqual(connectorStatus.locked, false) + assert.ok(requestHandlerMock.mock.calls.length >= 1, 'Should send StatusNotification') + }) + + await it('should send Available immediately when postTransactionDelay is 0', async () => { + // Arrange + assert.ok(station.stationInfo != null, 'stationInfo should be defined') + station.stationInfo.postTransactionDelay = 0 + + // Act + await OCPP20ServiceUtils.cleanupEndedTransaction(station, 1, connectorStatus) + + // Assert + assert.strictEqual(connectorStatus.transactionStarted, false) + assert.strictEqual(connectorStatus.transactionId, undefined) + assert.strictEqual(connectorStatus.locked, false) + assert.ok(requestHandlerMock.mock.calls.length >= 1, 'Should send StatusNotification') + }) + + await it('should skip cleanup when station stops during delay', async t => { + // Act + await withMockTimers(t, ['setTimeout'], async () => { + const promise = OCPP20ServiceUtils.cleanupEndedTransaction(station, 1, connectorStatus) + for (let i = 0; i < 10; i++) { + await flushMicrotasks() + } + station.started = false + t.mock.timers.tick(3000) + for (let i = 0; i < 10; i++) { + await flushMicrotasks() + } + await promise + }) + + // Assert — transactionStarted stays true (blocks ATG), transactionId cleared (blocks stopAll) + assert.strictEqual(connectorStatus.transactionStarted, true) + assert.strictEqual(connectorStatus.transactionId, undefined) + assert.strictEqual(connectorStatus.locked, true) + assert.strictEqual( + requestHandlerMock.mock.calls.length, + 0, + 'No StatusNotification should be sent' + ) + }) +})