]> Piment Noir Git Repositories - e-mobility-charging-stations-simulator.git/commitdiff
fix(ocpp2.0): make stopRunningTransactions version-aware for OCPP 2.0
authorJérôme Benoit <jerome.benoit@sap.com>
Thu, 19 Mar 2026 17:59:42 +0000 (18:59 +0100)
committerJérôme Benoit <jerome.benoit@sap.com>
Thu, 19 Mar 2026 18:05:23 +0000 (19:05 +0100)
src/charging-station/AutomaticTransactionGenerator.ts
src/charging-station/ChargingStation.ts
tests/charging-station/ChargingStation-StopRunningTransactions.test.ts [new file with mode: 0644]

index 22da82a83bbea4c0452d94b6fa2e3f84159011e1..d77bbf76ecfaae69278e2ea58d2fac8763c3b0ce 100644 (file)
@@ -533,6 +533,8 @@ export class AutomaticTransactionGenerator {
           .getConnectorStatus(connectorId)
           ?.transactionId?.toString()}`
       )
+      // TODO: OCPP 2.0 stations should use OCPP20ServiceUtils.requestStopTransaction() instead
+      // See: src/charging-station/ChargingStation.ts#stopRunningTransactionsOCPP20
       stopResponse = await this.chargingStation.stopTransactionOnConnector(connectorId, reason)
       // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
       ++this.connectorsStatus.get(connectorId)!.stopTransactionRequests
index 0021490d8232226dcd4218bbb8db29952fe9ab74..ff06512f8029f8d0b444138bf9318c13837c3f89 100644 (file)
@@ -44,6 +44,7 @@ import {
   type MeterValuesRequest,
   type MeterValuesResponse,
   type OCPP20MeterValue,
+  OCPP20ReasonEnumType,
   OCPP20TransactionEventEnumType,
   OCPP20TriggerReasonEnumType,
   OCPPVersion,
@@ -2727,6 +2728,13 @@ export class ChargingStation extends EventEmitter {
   }
 
   private async stopRunningTransactions (reason?: StopTransactionReason): Promise<void> {
+    if (
+      this.stationInfo?.ocppVersion === OCPPVersion.VERSION_20 ||
+      this.stationInfo?.ocppVersion === OCPPVersion.VERSION_201
+    ) {
+      await this.stopRunningTransactionsOCPP20(reason)
+      return
+    }
     if (this.hasEvses) {
       for (const [evseId, evseStatus] of this.evses) {
         if (evseId === 0) {
@@ -2747,6 +2755,49 @@ export class ChargingStation extends EventEmitter {
     }
   }
 
+  private async stopRunningTransactionsOCPP20 (reason?: StopTransactionReason): Promise<void> {
+    const stoppedReason =
+      reason != null ? (reason as unknown as OCPP20ReasonEnumType) : OCPP20ReasonEnumType.Local
+    const terminationPromises: Promise<unknown>[] = []
+
+    for (const [evseId, evseStatus] of this.evses) {
+      if (evseId === 0) {
+        continue
+      }
+      for (const [connectorId, connectorStatus] of evseStatus.connectors) {
+        if (
+          connectorStatus.transactionStarted === true ||
+          connectorStatus.transactionPending === true
+        ) {
+          logger.info(
+            `${this.logPrefix()} stopRunningTransactionsOCPP20: Stopping transaction ${connectorStatus.transactionId?.toString() ?? 'unknown'} on connector ${connectorId.toString()}`
+          )
+          terminationPromises.push(
+            OCPP20ServiceUtils.requestStopTransaction(
+              this,
+              connectorId,
+              evseId,
+              OCPP20TriggerReasonEnumType.StopAuthorized,
+              stoppedReason
+            ).catch((error: unknown) => {
+              logger.error(
+                `${this.logPrefix()} stopRunningTransactionsOCPP20: Error stopping transaction on connector ${connectorId.toString()}:`,
+                error
+              )
+            })
+          )
+        }
+      }
+    }
+
+    if (terminationPromises.length > 0) {
+      await Promise.all(terminationPromises)
+      logger.info(
+        `${this.logPrefix()} stopRunningTransactionsOCPP20: All transactions stopped on charging station`
+      )
+    }
+  }
+
   private stopWebSocketPing (): void {
     if (this.wsPingSetInterval != null) {
       clearInterval(this.wsPingSetInterval)
diff --git a/tests/charging-station/ChargingStation-StopRunningTransactions.test.ts b/tests/charging-station/ChargingStation-StopRunningTransactions.test.ts
new file mode 100644 (file)
index 0000000..bd32284
--- /dev/null
@@ -0,0 +1,223 @@
+/**
+ * @file Tests for ChargingStation stopRunningTransactions
+ * @description Verifies version-aware transaction stopping: OCPP 2.0 uses TransactionEvent(Ended),
+ *              OCPP 1.6 uses StopTransaction
+ */
+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 { EmptyObject, JsonType, StopTransactionReason } from '../../src/types/index.js'
+
+import { ChargingStation as ChargingStationClass } from '../../src/charging-station/ChargingStation.js'
+import { OCPPVersion } from '../../src/types/index.js'
+import { Constants } from '../../src/utils/index.js'
+import { setupConnectorWithTransaction, standardCleanup } from '../helpers/TestLifecycleHelpers.js'
+import { TEST_CHARGING_STATION_BASE_NAME } from './ChargingStationTestConstants.js'
+import { cleanupChargingStation, createMockChargingStation } from './ChargingStationTestUtils.js'
+
+interface TestableChargingStationPrivate {
+  stopRunningTransactions: (reason?: StopTransactionReason) => Promise<void>
+  stopRunningTransactionsOCPP20: (reason?: StopTransactionReason) => Promise<void>
+  stopTransactionOnConnector: (
+    connectorId: number,
+    reason?: StopTransactionReason
+  ) => Promise<unknown>
+}
+
+/**
+ * Binds private ChargingStation methods to a mock station instance for testing
+ * @param station - The mock station to bind methods to
+ */
+function bindPrivateMethods (station: ChargingStation): void {
+  const proto = ChargingStationClass.prototype as unknown as TestableChargingStationPrivate
+  const stationRecord = station as unknown as Record<string, unknown>
+  stationRecord.stopRunningTransactions = proto.stopRunningTransactions
+  stationRecord.stopRunningTransactionsOCPP20 = proto.stopRunningTransactionsOCPP20
+  stationRecord.stopTransactionOnConnector = proto.stopTransactionOnConnector
+}
+
+await describe('ChargingStation stopRunningTransactions', async () => {
+  let station: ChargingStation | undefined
+
+  beforeEach(() => {
+    station = undefined
+  })
+
+  afterEach(() => {
+    standardCleanup()
+    if (station != null) {
+      cleanupChargingStation(station)
+    }
+  })
+
+  await it('should send TransactionEvent(Ended) for OCPP 2.0 stations when stopping running transactions', async () => {
+    // Arrange
+    const sentRequests: { command: string; payload: Record<string, unknown> }[] = []
+    const requestHandlerMock = mock.fn(async (...args: unknown[]) => {
+      sentRequests.push({
+        command: args[1] as string,
+        payload: args[2] as Record<string, unknown>,
+      })
+      return Promise.resolve({} as EmptyObject)
+    })
+
+    const result = createMockChargingStation({
+      baseName: TEST_CHARGING_STATION_BASE_NAME,
+      connectorsCount: 2,
+      evseConfiguration: { evsesCount: 2 },
+      heartbeatInterval: Constants.DEFAULT_HEARTBEAT_INTERVAL,
+      ocppRequestService: {
+        requestHandler: requestHandlerMock,
+      },
+      stationInfo: {
+        ocppVersion: OCPPVersion.VERSION_201,
+      },
+      websocketPingInterval: Constants.DEFAULT_WEBSOCKET_PING_INTERVAL,
+    })
+    station = result.station
+    station.isWebSocketConnectionOpened = () => true
+    bindPrivateMethods(station)
+
+    setupConnectorWithTransaction(station, 1, { transactionId: 1001 })
+    const connector1 = station.getConnectorStatus(1)
+    if (connector1 != null) {
+      connector1.transactionId = 'tx-ocpp20-1001'
+    }
+
+    // Act
+    const testable = station as unknown as TestableChargingStationPrivate
+    await testable.stopRunningTransactions()
+
+    // Assert
+    const transactionEventCalls = sentRequests.filter(r => r.command === 'TransactionEvent')
+    assert.ok(transactionEventCalls.length > 0, 'Expected at least one TransactionEvent request')
+    const stopTransactionCalls = sentRequests.filter(r => r.command === 'StopTransaction')
+    assert.strictEqual(
+      stopTransactionCalls.length,
+      0,
+      'Should not send StopTransaction for OCPP 2.0'
+    )
+  })
+
+  await it('should send StopTransaction for OCPP 1.6 stations when stopping running transactions', async () => {
+    // Arrange
+    const sentRequests: { command: string; payload: Record<string, unknown> }[] = []
+    const requestHandlerMock = mock.fn(async (...args: unknown[]) => {
+      sentRequests.push({
+        command: args[1] as string,
+        payload: args[2] as Record<string, unknown>,
+      })
+      return Promise.resolve({ idTagInfo: { status: 'Accepted' } } as unknown as JsonType)
+    })
+
+    const result = createMockChargingStation({
+      baseName: TEST_CHARGING_STATION_BASE_NAME,
+      connectorsCount: 2,
+      ocppRequestService: {
+        requestHandler: requestHandlerMock,
+      },
+      stationInfo: {
+        ocppVersion: OCPPVersion.VERSION_16,
+      },
+    })
+    station = result.station
+    station.isWebSocketConnectionOpened = () => true
+    bindPrivateMethods(station)
+
+    setupConnectorWithTransaction(station, 1, { transactionId: 5001 })
+
+    // Act
+    const testable = station as unknown as TestableChargingStationPrivate
+    await testable.stopRunningTransactions()
+
+    // Assert
+    const stopTransactionCalls = sentRequests.filter(r => r.command === 'StopTransaction')
+    assert.ok(stopTransactionCalls.length > 0, 'Expected at least one StopTransaction request')
+    const transactionEventCalls = sentRequests.filter(r => r.command === 'TransactionEvent')
+    assert.strictEqual(
+      transactionEventCalls.length,
+      0,
+      'Should not send TransactionEvent for OCPP 1.6'
+    )
+  })
+
+  await it('should handle errors gracefully when OCPP 2.0 transaction stop fails', async () => {
+    // Arrange
+    const requestHandlerMock = mock.fn(async () => {
+      return Promise.reject(new Error('Simulated network error'))
+    })
+
+    const result = createMockChargingStation({
+      baseName: TEST_CHARGING_STATION_BASE_NAME,
+      connectorsCount: 2,
+      evseConfiguration: { evsesCount: 2 },
+      heartbeatInterval: Constants.DEFAULT_HEARTBEAT_INTERVAL,
+      ocppRequestService: {
+        requestHandler: requestHandlerMock,
+      },
+      stationInfo: {
+        ocppVersion: OCPPVersion.VERSION_201,
+      },
+      websocketPingInterval: Constants.DEFAULT_WEBSOCKET_PING_INTERVAL,
+    })
+    station = result.station
+    station.isWebSocketConnectionOpened = () => true
+    bindPrivateMethods(station)
+
+    setupConnectorWithTransaction(station, 1, { transactionId: 2001 })
+    const connector1 = station.getConnectorStatus(1)
+    if (connector1 != null) {
+      connector1.transactionId = 'tx-ocpp20-2001'
+    }
+
+    // Act & Assert — should not throw
+    const testable = station as unknown as TestableChargingStationPrivate
+    await testable.stopRunningTransactions()
+  })
+
+  await it('should also stop pending transactions for OCPP 2.0 stations', async () => {
+    // Arrange
+    const sentRequests: { command: string; payload: Record<string, unknown> }[] = []
+    const requestHandlerMock = mock.fn(async (...args: unknown[]) => {
+      sentRequests.push({
+        command: args[1] as string,
+        payload: args[2] as Record<string, unknown>,
+      })
+      return Promise.resolve({} as EmptyObject)
+    })
+
+    const result = createMockChargingStation({
+      baseName: TEST_CHARGING_STATION_BASE_NAME,
+      connectorsCount: 2,
+      evseConfiguration: { evsesCount: 2 },
+      heartbeatInterval: Constants.DEFAULT_HEARTBEAT_INTERVAL,
+      ocppRequestService: {
+        requestHandler: requestHandlerMock,
+      },
+      stationInfo: {
+        ocppVersion: OCPPVersion.VERSION_201,
+      },
+      websocketPingInterval: Constants.DEFAULT_WEBSOCKET_PING_INTERVAL,
+    })
+    station = result.station
+    station.isWebSocketConnectionOpened = () => true
+    bindPrivateMethods(station)
+
+    // Set up a pending transaction (not started, but pending)
+    const connector1 = station.getConnectorStatus(1)
+    if (connector1 != null) {
+      connector1.transactionPending = true
+      connector1.transactionStarted = false
+      connector1.transactionId = 'tx-pending-3001'
+    }
+
+    // Act
+    const testable = station as unknown as TestableChargingStationPrivate
+    await testable.stopRunningTransactions()
+
+    // Assert
+    const transactionEventCalls = sentRequests.filter(r => r.command === 'TransactionEvent')
+    assert.ok(transactionEventCalls.length > 0, 'Expected TransactionEvent for pending transaction')
+  })
+})