From: Jérôme Benoit Date: Fri, 17 Jul 2026 15:14:04 +0000 (+0200) Subject: refactor(ocpp): use iterateConnectors() in OCPP 1.6 incoming request service (#2015) X-Git-Tag: cli@v4.11.0~24 X-Git-Url: https://git.piment-noir.org/?a=commitdiff_plain;h=27093f1b26b36e7fcae30b4410a3191a8e9c17f1;p=e-mobility-charging-stations-simulator.git refactor(ocpp): use iterateConnectors() in OCPP 1.6 incoming request service (#2015) Closes #2014 --- diff --git a/src/charging-station/ocpp/1.6/OCPP16IncomingRequestService.ts b/src/charging-station/ocpp/1.6/OCPP16IncomingRequestService.ts index 208cabf0..153c9573 100644 --- a/src/charging-station/ocpp/1.6/OCPP16IncomingRequestService.ts +++ b/src/charging-station/ocpp/1.6/OCPP16IncomingRequestService.ts @@ -595,14 +595,11 @@ export class OCPP16IncomingRequestService extends OCPPIncomingRequestService 0 && valueChanged ) { - for ( - let connectorId = 1; - connectorId <= chargingStation.getNumberOfConnectors(); - connectorId++ - ) { - if (chargingStation.getConnectorStatus(connectorId)?.transactionStarted === true) { + for (const { connectorId, connectorStatus } of chargingStation.iterateConnectors(true)) { + if (connectorStatus.transactionStarted === true) { OCPP16ServiceUtils.stopUpdatedMeterValues(chargingStation, connectorId) OCPP16ServiceUtils.startUpdatedMeterValues( chargingStation, @@ -1169,12 +1162,13 @@ export class OCPP16IncomingRequestService extends OCPPIncomingRequestService { if (commandPayload.connectorId == null) { - for ( - let connectorId = 1; - connectorId <= chargingStation.getNumberOfConnectors(); - connectorId++ - ) { - const connectorStatus = chargingStation.getConnectorStatus(connectorId) + for (const { connectorId, connectorStatus } of chargingStation.iterateConnectors(true)) { const isReservedForOther = - connectorStatus?.status === OCPP16ChargePointStatus.Reserved && + connectorStatus.status === OCPP16ChargePointStatus.Reserved && !OCPP16ServiceUtils.hasReservation(chargingStation, connectorId, commandPayload.idTag) if ( chargingStation.isConnectorAvailable(connectorId) && - connectorStatus?.transactionStarted === false && + connectorStatus.transactionStarted === false && !isReservedForOther ) { commandPayload.connectorId = connectorId diff --git a/tests/charging-station/ocpp/1.6/OCPP16IncomingRequestService-Configuration.test.ts b/tests/charging-station/ocpp/1.6/OCPP16IncomingRequestService-Configuration.test.ts index b336fd20..f8a07059 100644 --- a/tests/charging-station/ocpp/1.6/OCPP16IncomingRequestService-Configuration.test.ts +++ b/tests/charging-station/ocpp/1.6/OCPP16IncomingRequestService-Configuration.test.ts @@ -5,20 +5,27 @@ */ import assert from 'node:assert/strict' -import { afterEach, beforeEach, describe, it } from 'node:test' +import { afterEach, beforeEach, describe, it, mock } from 'node:test' +import type { ChargingStation } from '../../../../src/charging-station/index.js' import type { ChangeConfigurationRequest, GetConfigurationRequest, } from '../../../../src/types/index.js' +import { OCPP16ServiceUtils } from '../../../../src/charging-station/ocpp/1.6/OCPP16ServiceUtils.js' import { OCPP16ConfigurationStatus, OCPP16StandardParametersKey, } from '../../../../src/types/index.js' -import { standardCleanup } from '../../../helpers/TestLifecycleHelpers.js' import { + setupConnectorWithTransaction, + standardCleanup, +} from '../../../helpers/TestLifecycleHelpers.js' +import { + createOCPP16EvseBackedContext, createOCPP16IncomingRequestTestContext, + createOCPP16NonContiguousConnectorsContext, type OCPP16IncomingRequestTestContext, upsertConfigurationKey, } from './OCPP16TestUtils.js' @@ -110,6 +117,66 @@ await describe('OCPP16IncomingRequestService — Configuration', async () => { assert.strictEqual(response.status, OCPP16ConfigurationStatus.NOT_SUPPORTED) }) + // @spec §5.4 — MeterValueSampleInterval restart scans connectors station-wide. + // On a non-contiguous layout {1, 3}, getNumberOfConnectors() returns 2 (a COUNT) + // while connector 3 lives beyond it, so a numeric 1..count loop would skip it. + await it('should restart updated MeterValues for every transacting connector, including the id beyond the connector count', () => { + // Arrange + const { station, testableService } = createOCPP16NonContiguousConnectorsContext() + upsertConfigurationKey(station, OCPP16StandardParametersKey.MeterValueSampleInterval, '60') + setupConnectorWithTransaction(station, 1, { transactionId: 100 }) + setupConnectorWithTransaction(station, 3, { transactionId: 300 }) + const startSpy = mock.method(OCPP16ServiceUtils, 'startUpdatedMeterValues', () => { + /* no-op: isolate the loop under test from interval side effects */ + }) + mock.method(OCPP16ServiceUtils, 'stopUpdatedMeterValues', () => { + /* no-op */ + }) + + // Act + const response = testableService.handleRequestChangeConfiguration(station, { + key: OCPP16StandardParametersKey.MeterValueSampleInterval, + value: '30', + }) + + // Assert — both connector 1 and connector 3 are restarted (old loop skipped 3) + assert.strictEqual(response.status, OCPP16ConfigurationStatus.ACCEPTED) + const restartedConnectorIds = startSpy.mock.calls + .map(call => (call.arguments as [ChargingStation, number, number])[1]) + .sort((a, b) => a - b) + assert.deepStrictEqual(restartedConnectorIds, [1, 3]) + }) + + // @spec §5.4 — the same station-wide restart scan must reach connectors spread + // across EVSEs. getNumberOfConnectors() is a COUNT unrelated to the max id, so a + // numeric 1..count loop cannot enumerate connector 3 living in a second EVSE. + await it('should restart updated MeterValues for transacting connectors spread across EVSEs', () => { + // Arrange + const { station, testableService } = createOCPP16EvseBackedContext() + upsertConfigurationKey(station, OCPP16StandardParametersKey.MeterValueSampleInterval, '60') + setupConnectorWithTransaction(station, 1, { transactionId: 100 }) + setupConnectorWithTransaction(station, 3, { transactionId: 300 }) + const startSpy = mock.method(OCPP16ServiceUtils, 'startUpdatedMeterValues', () => { + /* no-op: isolate the loop under test from interval side effects */ + }) + mock.method(OCPP16ServiceUtils, 'stopUpdatedMeterValues', () => { + /* no-op */ + }) + + // Act + const response = testableService.handleRequestChangeConfiguration(station, { + key: OCPP16StandardParametersKey.MeterValueSampleInterval, + value: '30', + }) + + // Assert — both EVSE-backed connectors are restarted (old loop missed EVSE 2) + assert.strictEqual(response.status, OCPP16ConfigurationStatus.ACCEPTED) + const restartedConnectorIds = startSpy.mock.calls + .map(call => (call.arguments as [ChargingStation, number, number])[1]) + .sort((a, b) => a - b) + assert.deepStrictEqual(restartedConnectorIds, [1, 3]) + }) + // --------------------------------------------------------------------------- // GetConfiguration (§5.8) // --------------------------------------------------------------------------- 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 664dd19d..42c3ca87 100644 --- a/tests/charging-station/ocpp/1.6/OCPP16IncomingRequestService-RemoteStartTransaction.test.ts +++ b/tests/charging-station/ocpp/1.6/OCPP16IncomingRequestService-RemoteStartTransaction.test.ts @@ -26,8 +26,10 @@ import { } from '../../../helpers/TestLifecycleHelpers.js' import { TEST_ID_TAG } from '../../ChargingStationTestConstants.js' import { + createOCPP16EvseBackedContext, createOCPP16IncomingRequestTestContext, createOCPP16ListenerStation, + createOCPP16NonContiguousConnectorsContext, type OCPP16IncomingRequestTestContext, } from './OCPP16TestUtils.js' @@ -180,6 +182,27 @@ await describe('OCPP16IncomingRequestService — RemoteStartTransaction', async assert.strictEqual(request.connectorId, 2) }) + // @spec §5.11 — connector selection with no connectorId is implementation-defined; + // this simulator preserves lowest-available-first. Guards the first-match scan's + // iteration-order determinism: a non-ascending iterateConnectors() yield would flip + // the selected connector. + await it('should auto-select the lowest-id connector when several are available', async () => { + // Arrange + const { station, testableService } = createOCPP16IncomingRequestTestContext({ + connectorsCount: 3, + }) + const request: RemoteStartTransactionRequest = { + idTag: TEST_ID_TAG, + } + + // Act + const response = await testableService.handleRequestRemoteStartTransaction(station, request) + + // Assert — connectors 1, 2, 3 all available → connector 1 (lowest) is selected + assert.strictEqual(response.status, GenericStatus.Accepted) + assert.strictEqual(request.connectorId, 1) + }) + // @spec §5.11 — All connectors Inoperative, no connectorId specified await it('should reject remote start transaction when all connectors are inoperative and no connectorId specified', async () => { // Arrange @@ -204,6 +227,46 @@ await describe('OCPP16IncomingRequestService — RemoteStartTransaction', async assert.strictEqual(response.status, GenericStatus.Rejected) }) + // --- Auto-selection across non-contiguous / EVSE-backed layouts --- + + await describe('auto-selection station-wide connector scan (§5.11)', async () => { + // On a non-contiguous layout {1, 3}, getNumberOfConnectors() returns 2 (a COUNT) + // while connector 3 lives beyond it, so a numeric 1..count loop never reaches it. + await it('should auto-select an available connector whose id exceeds the connector count', async () => { + // Arrange + const { station, testableService } = createOCPP16NonContiguousConnectorsContext() + const connector1 = station.getConnectorStatus(1) + if (connector1 != null) { + connector1.availability = AvailabilityType.Inoperative + } + const request: RemoteStartTransactionRequest = { idTag: TEST_ID_TAG } + + // Act + const response = await testableService.handleRequestRemoteStartTransaction(station, request) + + // Assert — connector 3 is selected (old loop never reached id 3) + assert.strictEqual(response.status, GenericStatus.Accepted) + assert.strictEqual(request.connectorId, 3) + }) + + await it('should auto-select an available connector living in a second EVSE beyond the connector count', async () => { + // Arrange + const { station, testableService } = createOCPP16EvseBackedContext() + const connector1 = station.getConnectorStatus(1) + if (connector1 != null) { + connector1.availability = AvailabilityType.Inoperative + } + const request: RemoteStartTransactionRequest = { idTag: TEST_ID_TAG } + + // Act + const response = await testableService.handleRequestRemoteStartTransaction(station, request) + + // Assert — connector 3 (EVSE 2) is reached and selected + assert.strictEqual(response.status, GenericStatus.Accepted) + assert.strictEqual(request.connectorId, 3) + }) + }) + // --- Transaction state --- // @spec §5.11 — All connectors have active transactions, no connectorId specified diff --git a/tests/charging-station/ocpp/1.6/OCPP16IncomingRequestService-SmartCharging.test.ts b/tests/charging-station/ocpp/1.6/OCPP16IncomingRequestService-SmartCharging.test.ts index 51236ab2..80f9cf21 100644 --- a/tests/charging-station/ocpp/1.6/OCPP16IncomingRequestService-SmartCharging.test.ts +++ b/tests/charging-station/ocpp/1.6/OCPP16IncomingRequestService-SmartCharging.test.ts @@ -7,7 +7,9 @@ import assert from 'node:assert/strict' import { afterEach, beforeEach, describe, it } from 'node:test' +import type { ChargingStation } from '../../../../src/charging-station/index.js' import type { + OCPP16ChargingProfile, OCPP16ClearChargingProfileRequest, OCPP16GetCompositeScheduleRequest, SetChargingProfileRequest, @@ -24,10 +26,24 @@ import { standardCleanup } from '../../../helpers/TestLifecycleHelpers.js' import { ChargingProfileFixtures, createOCPP16IncomingRequestTestContext, + createOCPP16NonContiguousConnectorsContext, type OCPP16IncomingRequestTestContext, upsertConfigurationKey, } from './OCPP16TestUtils.js' +const attachComposableProfile = ( + station: ChargingStation, + connectorId: number, + profile: OCPP16ChargingProfile +): void => { + profile.chargingSchedule.startSchedule = new Date() + profile.chargingSchedule.duration = 3600 + const connectorStatus = station.getConnectorStatus(connectorId) + if (connectorStatus != null) { + connectorStatus.chargingProfiles = [profile] + } +} + await describe('OCPP16IncomingRequestService — SmartCharging', async () => { let context: OCPP16IncomingRequestTestContext @@ -366,5 +382,60 @@ await describe('OCPP16IncomingRequestService — SmartCharging', async () => { // Assert assert.strictEqual(response.status, GenericStatus.Rejected) }) + + // @spec §9.2 — connector-0 aggregation scans every connector (skipZero=false). + // On a non-contiguous layout {1, 3}, getNumberOfConnectors() returns 2 (a COUNT) + // while connector 3 lives beyond it, so a numeric 1..count loop would miss its + // profile and reject the connector-0 composite schedule. + await it('should include a profile from a connector whose id exceeds the connector count in the connector-0 composite schedule', () => { + // Arrange + const { station, testableService } = createOCPP16NonContiguousConnectorsContext() + upsertConfigurationKey( + station, + OCPP16StandardParametersKey.SupportedFeatureProfiles, + 'Core,SmartCharging' + ) + attachComposableProfile(station, 3, ChargingProfileFixtures.createTxDefaultProfile()) + const request: OCPP16GetCompositeScheduleRequest = { connectorId: 0, duration: 3600 } + + // Act + const response = testableService.handleRequestGetCompositeSchedule(station, request) + + // Assert — connector 3's profile is aggregated (old loop missed it → Rejected) + assert.strictEqual(response.status, GenericStatus.Accepted) + assert.strictEqual(response.connectorId, 0) + }) + + // @spec §9.2 — connector-0 aggregation must iterate connector 0 itself + // (skipZero=false). A ChargePointMaxProfile on connector 0 is not re-concatenated + // for other connectors by getConnectorChargingProfiles (that concat pulls only + // connector-0 TX_DEFAULT profiles), so switching this site to skipZero=true would + // drop it and this test would fail. + await it('should include a station-wide (connector-0) ChargePointMaxProfile in the connector-0 composite schedule', () => { + // Arrange + const { station, testableService } = createOCPP16IncomingRequestTestContext({ + connectorsCount: 2, + }) + upsertConfigurationKey( + station, + OCPP16StandardParametersKey.SupportedFeatureProfiles, + 'Core,SmartCharging' + ) + for (const connectorId of [1, 2]) { + const connectorStatus = station.getConnectorStatus(connectorId) + if (connectorStatus != null) { + connectorStatus.chargingProfiles = [] + } + } + attachComposableProfile(station, 0, ChargingProfileFixtures.createChargePointMaxProfile()) + const request: OCPP16GetCompositeScheduleRequest = { connectorId: 0, duration: 3600 } + + // Act + const response = testableService.handleRequestGetCompositeSchedule(station, request) + + // Assert — connector-0 profile is included (would be dropped under skipZero=true) + assert.strictEqual(response.status, GenericStatus.Accepted) + assert.strictEqual(response.connectorId, 0) + }) }) }) diff --git a/tests/charging-station/ocpp/1.6/OCPP16IncomingRequestService-TriggerMessage.test.ts b/tests/charging-station/ocpp/1.6/OCPP16IncomingRequestService-TriggerMessage.test.ts index d05d4572..0e8972c8 100644 --- a/tests/charging-station/ocpp/1.6/OCPP16IncomingRequestService-TriggerMessage.test.ts +++ b/tests/charging-station/ocpp/1.6/OCPP16IncomingRequestService-TriggerMessage.test.ts @@ -25,22 +25,65 @@ import { OCPP16FirmwareStatus, OCPP16IncomingRequestCommand, OCPP16MessageTrigger, + OCPP16MeterValueUnit, OCPP16RequestCommand, OCPP16StandardParametersKey, OCPP16TriggerMessageStatus, OCPPVersion, } from '../../../../src/types/index.js' import { Constants, logger } from '../../../../src/utils/index.js' -import { flushMicrotasks, standardCleanup } from '../../../helpers/TestLifecycleHelpers.js' +import { + flushMicrotasks, + setupConnectorWithTransaction, + standardCleanup, +} from '../../../helpers/TestLifecycleHelpers.js' import { TEST_CHARGING_STATION_BASE_NAME } from '../../ChargingStationTestConstants.js' import { createMockChargingStation } from '../../helpers/StationHelpers.js' import { + createOCPP16EvseBackedContext, createOCPP16IncomingRequestTestContext, createOCPP16ListenerStation, + createOCPP16NonContiguousConnectorsContext, type OCPP16IncomingRequestTestContext, upsertConfigurationKey, } from './OCPP16TestUtils.js' +const enableConnectorMeterValues = (station: ChargingStation, connectorId: number): void => { + const connectorStatus = station.getConnectorStatus(connectorId) + if (connectorStatus != null) { + connectorStatus.MeterValues = [{ unit: OCPP16MeterValueUnit.WATT_HOUR, value: '0' }] + } +} + +const setRecordingRequestHandler = (station: ChargingStation): ReturnType => { + const handler = mock.fn(async () => Promise.resolve({})) + ;( + station.ocppRequestService as unknown as { + requestHandler: (...args: unknown[]) => Promise + } + ).requestHandler = handler + return handler +} + +const meterValuesBroadcastConnectorIds = (handler: ReturnType): number[] => + handler.mock.calls + .map(call => call.arguments as [unknown, OCPP16RequestCommand, { connectorId?: number }]) + .filter(([, command]) => command === OCPP16RequestCommand.METER_VALUES) + .map(([, , payload]) => payload.connectorId) + .filter((connectorId): connectorId is number => connectorId != null) + .sort((a, b) => a - b) + +const emitAcceptedTrigger = ( + service: OCPP16IncomingRequestService, + station: ChargingStation, + request: OCPP16TriggerMessageRequest +): void => { + const response: OCPP16TriggerMessageResponse = { + status: OCPP16TriggerMessageStatus.ACCEPTED, + } + service.emit(OCPP16IncomingRequestCommand.TRIGGER_MESSAGE, station, request, response) +} + await describe('OCPP16IncomingRequestService — TriggerMessage', async () => { let context: OCPP16IncomingRequestTestContext @@ -121,6 +164,58 @@ await describe('OCPP16IncomingRequestService — TriggerMessage', async () => { }) }) + await describe('MeterValues broadcast (no connectorId, station-wide scan)', async () => { + await it('should broadcast MeterValues for both contiguous transacting connectors', async () => { + const { incomingRequestService, station } = createOCPP16IncomingRequestTestContext({ + connectorsCount: 2, + }) + setupConnectorWithTransaction(station, 1, { transactionId: 100 }) + setupConnectorWithTransaction(station, 2, { transactionId: 200 }) + enableConnectorMeterValues(station, 1) + enableConnectorMeterValues(station, 2) + const handler = setRecordingRequestHandler(station) + + emitAcceptedTrigger(incomingRequestService, station, { + requestedMessage: OCPP16MessageTrigger.MeterValues, + }) + await flushMicrotasks() + + assert.deepStrictEqual(meterValuesBroadcastConnectorIds(handler), [1, 2]) + }) + + await it('should broadcast MeterValues for a transacting connector whose id exceeds the connector count', async () => { + const { incomingRequestService, station } = createOCPP16NonContiguousConnectorsContext() + setupConnectorWithTransaction(station, 1, { transactionId: 100 }) + setupConnectorWithTransaction(station, 3, { transactionId: 300 }) + enableConnectorMeterValues(station, 1) + enableConnectorMeterValues(station, 3) + const handler = setRecordingRequestHandler(station) + + emitAcceptedTrigger(incomingRequestService, station, { + requestedMessage: OCPP16MessageTrigger.MeterValues, + }) + await flushMicrotasks() + + assert.deepStrictEqual(meterValuesBroadcastConnectorIds(handler), [1, 3]) + }) + + await it('should broadcast MeterValues for transacting connectors spread across EVSEs', async () => { + const { incomingRequestService, station } = createOCPP16EvseBackedContext() + setupConnectorWithTransaction(station, 1, { transactionId: 100 }) + setupConnectorWithTransaction(station, 3, { transactionId: 300 }) + enableConnectorMeterValues(station, 1) + enableConnectorMeterValues(station, 3) + const handler = setRecordingRequestHandler(station) + + emitAcceptedTrigger(incomingRequestService, station, { + requestedMessage: OCPP16MessageTrigger.MeterValues, + }) + await flushMicrotasks() + + assert.deepStrictEqual(meterValuesBroadcastConnectorIds(handler), [1, 3]) + }) + }) + await describe('DiagnosticsStatusNotification trigger', async () => { await it('should return Accepted for DiagnosticsStatusNotification trigger', () => { // Arrange diff --git a/tests/charging-station/ocpp/1.6/OCPP16TestUtils.ts b/tests/charging-station/ocpp/1.6/OCPP16TestUtils.ts index 4c2abf1c..65cd5874 100644 --- a/tests/charging-station/ocpp/1.6/OCPP16TestUtils.ts +++ b/tests/charging-station/ocpp/1.6/OCPP16TestUtils.ts @@ -11,6 +11,8 @@ import type { ChargingStation } from '../../../../src/charging-station/index.js' import type { ChargingStationInfo, ConfigurationKey, + ConnectorStatus, + EvseStatus, JsonObject, OCPP16ChargingProfile, OCPP16ChargingSchedulePeriod, @@ -38,6 +40,7 @@ import { import { Constants } from '../../../../src/utils/index.js' import { TEST_CHARGING_STATION_BASE_NAME, TEST_ID_TAG } from '../../ChargingStationTestConstants.js' import { + createConnectorStatus, createMockChargingStation, type MockChargingStation, type MockOCPPRequestService, @@ -115,6 +118,33 @@ export function createMeterValuesTemplate (entries: OCPP16SampledValue[]): Sampl return entries } +/** + * Create an EVSE-backed OCPP 1.6 incoming-request test context where connectors span + * two EVSEs with a non-contiguous global id gap: EVSE 1 → connector 1, EVSE 2 → + * connector 3. `getNumberOfConnectors()` returns 2 while connector 3 lives beyond that + * count, exercising station-wide scans across EVSEs. + * @returns Context whose EVSE-backed station exposes connectors {0, 1, 3}. + */ +export function createOCPP16EvseBackedContext (): OCPP16IncomingRequestTestContext { + const incomingRequestService = new OCPP16IncomingRequestService() + const testableService = createTestableIncomingRequestService(incomingRequestService) + const { station } = createMockChargingStation({ + connectorsCount: 2, + evseConfiguration: { evsesCount: 2 }, + stationInfo: { + ocppStrictCompliance: false, + ocppVersion: OCPPVersion.VERSION_16, + }, + websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS, + }) + const evse2 = (station as unknown as { evses: Map }).evses.get(2) + if (evse2 != null) { + evse2.connectors.delete(2) + evse2.connectors.set(3, createConnectorStatus(3)) + } + return { incomingRequestService, station, testableService } +} + /** * Create a standard OCPP 1.6 incoming request test context with service, * testable wrapper, and mock charging station. @@ -171,6 +201,19 @@ export function createOCPP16ListenerStation (baseName: string): { return { requestHandlerMock, station } } +/** + * Create an OCPP 1.6 incoming-request test context whose station has non-contiguous + * connector ids {1, 3} (connector 2 dropped from a 3-connector flat layout). + * `getNumberOfConnectors()` then returns 2 (a COUNT) while the highest connector id is + * 3 — the exact condition a `1..getNumberOfConnectors()` numeric loop mishandles. + * @returns Context whose flat station exposes connectors {0, 1, 3}. + */ +export function createOCPP16NonContiguousConnectorsContext (): OCPP16IncomingRequestTestContext { + const context = createOCPP16IncomingRequestTestContext({ connectorsCount: 3 }) + ;(context.station as unknown as { connectors: Map }).connectors.delete(2) + return context +} + /** * Create a standard OCPP 1.6 request test context with response service, * request service, testable wrapper, and mock charging station.