From: Jérôme Benoit Date: Thu, 19 Mar 2026 17:59:42 +0000 (+0100) Subject: fix(ocpp2.0): make stopRunningTransactions version-aware for OCPP 2.0 X-Git-Tag: ocpp-server@v3.2.0~24 X-Git-Url: https://git.piment-noir.org/?a=commitdiff_plain;h=ede4205df134de4df4de088f82a80090742029ea;p=e-mobility-charging-stations-simulator.git fix(ocpp2.0): make stopRunningTransactions version-aware for OCPP 2.0 --- diff --git a/src/charging-station/AutomaticTransactionGenerator.ts b/src/charging-station/AutomaticTransactionGenerator.ts index 22da82a8..d77bbf76 100644 --- a/src/charging-station/AutomaticTransactionGenerator.ts +++ b/src/charging-station/AutomaticTransactionGenerator.ts @@ -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 diff --git a/src/charging-station/ChargingStation.ts b/src/charging-station/ChargingStation.ts index 0021490d..ff06512f 100644 --- a/src/charging-station/ChargingStation.ts +++ b/src/charging-station/ChargingStation.ts @@ -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 { + 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 { + const stoppedReason = + reason != null ? (reason as unknown as OCPP20ReasonEnumType) : OCPP20ReasonEnumType.Local + const terminationPromises: Promise[] = [] + + 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 index 00000000..bd322847 --- /dev/null +++ b/tests/charging-station/ChargingStation-StopRunningTransactions.test.ts @@ -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 + stopRunningTransactionsOCPP20: (reason?: StopTransactionReason) => Promise + stopTransactionOnConnector: ( + connectorId: number, + reason?: StopTransactionReason + ) => Promise +} + +/** + * 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 + 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 }[] = [] + const requestHandlerMock = mock.fn(async (...args: unknown[]) => { + sentRequests.push({ + command: args[1] as string, + payload: args[2] as Record, + }) + 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 }[] = [] + const requestHandlerMock = mock.fn(async (...args: unknown[]) => { + sentRequests.push({ + command: args[1] as string, + payload: args[2] as Record, + }) + 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 }[] = [] + const requestHandlerMock = mock.fn(async (...args: unknown[]) => { + sentRequests.push({ + command: args[1] as string, + payload: args[2] as Record, + }) + 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') + }) +})