From: Jérôme Benoit Date: Fri, 17 Apr 2026 15:33:03 +0000 (+0200) Subject: fix(ocpp2): restore connector cleanup in TransactionEvent(Ended) response handler X-Git-Tag: cli@v4.5.0~35 X-Git-Url: https://git.piment-noir.org/?a=commitdiff_plain;h=80a3a80f7f6927d3355520a88d003d516c04711b;p=e-mobility-charging-stations-simulator.git fix(ocpp2): restore connector cleanup in TransactionEvent(Ended) response handler Commit 710db15c removed cleanup from the Ended response handler assuming callers always handle it. The UI broadcast channel passthrough bypasses all caller-side cleanup, leaving connectors stuck in Occupied state. Restore cleanupEndedTransaction in handleResponseTransactionEvent for the Ended case, making the response handler the authoritative cleanup point for all online paths. Add idempotency guard to cleanupEndedTransaction so existing caller-side calls (needed for offline fallback) become no-ops when the response handler already ran. Fix unconditional Available status to respect ChangeAvailability(Inoperative) per G03. --- diff --git a/src/charging-station/ocpp/2.0/OCPP20ResponseService.ts b/src/charging-station/ocpp/2.0/OCPP20ResponseService.ts index cbe58fa2..facc618a 100644 --- a/src/charging-station/ocpp/2.0/OCPP20ResponseService.ts +++ b/src/charging-station/ocpp/2.0/OCPP20ResponseService.ts @@ -386,11 +386,11 @@ export class OCPP20ResponseService extends OCPPResponseService { * @param payload - The TransactionEvent response payload from CSMS * @param requestPayload - The original TransactionEvent request payload */ - private handleResponseTransactionEvent ( + private async handleResponseTransactionEvent ( chargingStation: ChargingStation, payload: OCPP20TransactionEventResponse, requestPayload: OCPP20TransactionEventRequest - ): void { + ): Promise { logger.debug( `${chargingStation.logPrefix()} ${moduleName}.handleResponseTransactionEvent: TransactionEvent(${requestPayload.eventType}) response received` ) @@ -403,7 +403,12 @@ export class OCPP20ResponseService extends OCPPResponseService { switch (requestPayload.eventType) { case OCPP20TransactionEventEnumType.Ended: - if (connectorId != null) { + if (connectorId != null && connectorStatus != null) { + await OCPP20ServiceUtils.cleanupEndedTransaction( + chargingStation, + connectorId, + connectorStatus + ) logger.info( `${chargingStation.logPrefix()} ${moduleName}.handleResponseTransactionEvent: Transaction ${requestPayload.transactionInfo.transactionId} ENDED on connector ${connectorId.toString()}` ) diff --git a/src/charging-station/ocpp/2.0/OCPP20ServiceUtils.ts b/src/charging-station/ocpp/2.0/OCPP20ServiceUtils.ts index 5ad86705..66360c3e 100644 --- a/src/charging-station/ocpp/2.0/OCPP20ServiceUtils.ts +++ b/src/charging-station/ocpp/2.0/OCPP20ServiceUtils.ts @@ -192,12 +192,23 @@ export class OCPP20ServiceUtils { connectorId: number, connectorStatus: ConnectorStatus ): Promise { + if ( + connectorStatus.transactionStarted !== true && + connectorStatus.transactionPending !== true + ) { + return + } OCPP20ServiceUtils.stopUpdatedMeterValues(chargingStation, connectorId) resetConnectorStatus(connectorStatus) connectorStatus.locked = false + const targetStatus = + chargingStation.isChargingStationAvailable() && + chargingStation.isConnectorAvailable(connectorId) + ? ConnectorStatusEnum.Available + : ConnectorStatusEnum.Unavailable await sendAndSetConnectorStatus(chargingStation, { connectorId, - connectorStatus: ConnectorStatusEnum.Available, + connectorStatus: targetStatus, } as unknown as OCPP20StatusNotificationRequest) } diff --git a/tests/charging-station/ocpp/2.0/OCPP20ResponseService-TransactionEvent.test.ts b/tests/charging-station/ocpp/2.0/OCPP20ResponseService-TransactionEvent.test.ts index daaec841..298eff55 100644 --- a/tests/charging-station/ocpp/2.0/OCPP20ResponseService-TransactionEvent.test.ts +++ b/tests/charging-station/ocpp/2.0/OCPP20ResponseService-TransactionEvent.test.ts @@ -39,7 +39,7 @@ interface TestableOCPP20ResponseService { chargingStation: ChargingStation, payload: OCPP20TransactionEventResponse, requestPayload: OCPP20TransactionEventRequest - ) => void + ) => Promise } /** @@ -105,7 +105,7 @@ await describe('D01 - TransactionEvent Response', async () => { standardCleanup() }) - await it('should not stop transaction when idTokenInfo status is Accepted', () => { + await it('should not stop transaction when idTokenInfo status is Accepted', async () => { // Arrange const mockDeauthTransaction = mock.method( OCPP20ServiceUtils, @@ -120,13 +120,13 @@ await describe('D01 - TransactionEvent Response', async () => { const requestPayload = buildTransactionEventRequest(TEST_TRANSACTION_UUID) // Act - testable.handleResponseTransactionEvent(station, payload, requestPayload) + await testable.handleResponseTransactionEvent(station, payload, requestPayload) // Assert assert.strictEqual(mockDeauthTransaction.mock.calls.length, 0) }) - await it('should stop only the specific transaction when idTokenInfo status is Invalid', () => { + await it('should stop only the specific transaction when idTokenInfo status is Invalid', async () => { // Arrange const mockDeauthTransaction = mock.method( OCPP20ServiceUtils, @@ -141,7 +141,7 @@ await describe('D01 - TransactionEvent Response', async () => { const requestPayload = buildTransactionEventRequest(TEST_TRANSACTION_UUID) // Act - testable.handleResponseTransactionEvent(station, payload, requestPayload) + await testable.handleResponseTransactionEvent(station, payload, requestPayload) // Assert — only the specific connector (1) on EVSE (1) is stopped assert.strictEqual(mockDeauthTransaction.mock.calls.length, 1) @@ -150,7 +150,7 @@ await describe('D01 - TransactionEvent Response', async () => { assert.strictEqual(mockDeauthTransaction.mock.calls[0].arguments[2], 1) }) - await it('should stop only the specific transaction when idTokenInfo status is Blocked', () => { + await it('should stop only the specific transaction when idTokenInfo status is Blocked', async () => { // Arrange const mockDeauthTransaction = mock.method( OCPP20ServiceUtils, @@ -165,14 +165,14 @@ await describe('D01 - TransactionEvent Response', async () => { const requestPayload = buildTransactionEventRequest(TEST_TRANSACTION_UUID) // Act - testable.handleResponseTransactionEvent(station, payload, requestPayload) + await testable.handleResponseTransactionEvent(station, payload, requestPayload) // Assert assert.strictEqual(mockDeauthTransaction.mock.calls.length, 1) assert.strictEqual(mockDeauthTransaction.mock.calls[0].arguments[0], station) }) - await it('should not stop transaction when only chargingPriority is present', () => { + await it('should not stop transaction when only chargingPriority is present', async () => { // Arrange const mockDeauthTransaction = mock.method( OCPP20ServiceUtils, @@ -185,13 +185,13 @@ await describe('D01 - TransactionEvent Response', async () => { const requestPayload = buildTransactionEventRequest(TEST_TRANSACTION_UUID) // Act - testable.handleResponseTransactionEvent(station, payload, requestPayload) + await testable.handleResponseTransactionEvent(station, payload, requestPayload) // Assert assert.strictEqual(mockDeauthTransaction.mock.calls.length, 0) }) - await it('should handle empty response without stopping transaction', () => { + await it('should handle empty response without stopping transaction', async () => { // Arrange const mockDeauthTransaction = mock.method( OCPP20ServiceUtils, @@ -202,13 +202,13 @@ await describe('D01 - TransactionEvent Response', async () => { const requestPayload = buildTransactionEventRequest(TEST_TRANSACTION_UUID) // Act - testable.handleResponseTransactionEvent(station, payload, requestPayload) + await testable.handleResponseTransactionEvent(station, payload, requestPayload) // Assert assert.strictEqual(mockDeauthTransaction.mock.calls.length, 0) }) - await it('should stop only the specific transaction when idTokenInfo status is Expired', () => { + await it('should stop only the specific transaction when idTokenInfo status is Expired', async () => { // Arrange const mockDeauthTransaction = mock.method( OCPP20ServiceUtils, @@ -223,13 +223,13 @@ await describe('D01 - TransactionEvent Response', async () => { const requestPayload = buildTransactionEventRequest(TEST_TRANSACTION_UUID) // Act - testable.handleResponseTransactionEvent(station, payload, requestPayload) + await testable.handleResponseTransactionEvent(station, payload, requestPayload) // Assert assert.strictEqual(mockDeauthTransaction.mock.calls.length, 1) }) - await it('should stop only the specific transaction when idTokenInfo status is NoCredit', () => { + await it('should stop only the specific transaction when idTokenInfo status is NoCredit', async () => { // Arrange const mockDeauthTransaction = mock.method( OCPP20ServiceUtils, @@ -244,13 +244,13 @@ await describe('D01 - TransactionEvent Response', async () => { const requestPayload = buildTransactionEventRequest(TEST_TRANSACTION_UUID) // Act - testable.handleResponseTransactionEvent(station, payload, requestPayload) + await testable.handleResponseTransactionEvent(station, payload, requestPayload) // Assert assert.strictEqual(mockDeauthTransaction.mock.calls.length, 1) }) - await it('should not stop transaction when response has totalCost and updatedPersonalMessage', () => { + await it('should not stop transaction when response has totalCost and updatedPersonalMessage', async () => { // Arrange const mockDeauthTransaction = mock.method( OCPP20ServiceUtils, @@ -267,13 +267,13 @@ await describe('D01 - TransactionEvent Response', async () => { const requestPayload = buildTransactionEventRequest(TEST_TRANSACTION_UUID) // Act - testable.handleResponseTransactionEvent(station, payload, requestPayload) + await testable.handleResponseTransactionEvent(station, payload, requestPayload) // Assert assert.strictEqual(mockDeauthTransaction.mock.calls.length, 0) }) - await it('should stop only the targeted transaction on multi-EVSE station', () => { + await it('should stop only the targeted transaction on multi-EVSE station', async () => { // Set up a 2-EVSE station with active transactions on both EVSEs const txn1: UUIDv4 = '00000000-0000-0000-0000-000000000010' const txn2: UUIDv4 = '00000000-0000-0000-0000-000000000020' @@ -311,7 +311,7 @@ await describe('D01 - TransactionEvent Response', async () => { const multiTestable = createTestableResponseService(new OCPP20ResponseService()) // Act — reject EVSE 1's transaction only - multiTestable.handleResponseTransactionEvent( + await multiTestable.handleResponseTransactionEvent( multiStation, payload, buildTransactionEventRequest(txn1) diff --git a/tests/charging-station/ocpp/2.0/OCPP20ServiceUtils-TransactionEvent.test.ts b/tests/charging-station/ocpp/2.0/OCPP20ServiceUtils-TransactionEvent.test.ts index 5222feca..b77a00a9 100644 --- a/tests/charging-station/ocpp/2.0/OCPP20ServiceUtils-TransactionEvent.test.ts +++ b/tests/charging-station/ocpp/2.0/OCPP20ServiceUtils-TransactionEvent.test.ts @@ -1971,6 +1971,11 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => { errorStation.isWebSocketConnectionOpened = () => true + const connectorStatus = errorStation.getConnectorStatus(connectorId) + if (connectorStatus != null) { + connectorStatus.transactionStarted = true + } + await OCPP20ServiceUtils.sendQueuedTransactionEvents(errorStation, connectorId) assert.strictEqual(callCount, 4)