]> Piment Noir Git Repositories - e-mobility-charging-stations-simulator.git/commitdiff
fix(ocpp2): restore connector cleanup in TransactionEvent(Ended) response handler
authorJérôme Benoit <jerome.benoit@sap.com>
Fri, 17 Apr 2026 15:33:03 +0000 (17:33 +0200)
committerJérôme Benoit <jerome.benoit@sap.com>
Fri, 17 Apr 2026 15:33:03 +0000 (17:33 +0200)
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.

src/charging-station/ocpp/2.0/OCPP20ResponseService.ts
src/charging-station/ocpp/2.0/OCPP20ServiceUtils.ts
tests/charging-station/ocpp/2.0/OCPP20ResponseService-TransactionEvent.test.ts
tests/charging-station/ocpp/2.0/OCPP20ServiceUtils-TransactionEvent.test.ts

index cbe58fa23578edfe5e26b400f9fde46594905f4e..facc618a6aa0e56f4c619298d0d296a3d8142cc9 100644 (file)
@@ -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<void> {
     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()}`
           )
index 5ad867057a44b0357418abeab98342cc8d46afad..66360c3e1ccb1c602d00272fd293cf63accf4f98 100644 (file)
@@ -192,12 +192,23 @@ export class OCPP20ServiceUtils {
     connectorId: number,
     connectorStatus: ConnectorStatus
   ): Promise<void> {
+    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)
   }
 
index daaec841d22d73f8c7fabbaba900df1065a5dc65..298eff55e7f5cb13806960deac89c51d83cfd062 100644 (file)
@@ -39,7 +39,7 @@ interface TestableOCPP20ResponseService {
     chargingStation: ChargingStation,
     payload: OCPP20TransactionEventResponse,
     requestPayload: OCPP20TransactionEventRequest
-  ) => void
+  ) => Promise<void>
 }
 
 /**
@@ -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)
index 5222feca55ecc9e3a007e99a052a85425a05066f..b77a00a92b85a95ca16c7c7b351cc1408154e7a9 100644 (file)
@@ -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)