]> Piment Noir Git Repositories - e-mobility-charging-stations-simulator.git/commitdiff
refactor(ocpp): use iterateConnectors() in OCPP 1.6 incoming request service (#2015)
authorJérôme Benoit <jerome.benoit@piment-noir.org>
Fri, 17 Jul 2026 15:14:04 +0000 (17:14 +0200)
committerGitHub <noreply@github.com>
Fri, 17 Jul 2026 15:14:04 +0000 (17:14 +0200)
Closes #2014

src/charging-station/ocpp/1.6/OCPP16IncomingRequestService.ts
tests/charging-station/ocpp/1.6/OCPP16IncomingRequestService-Configuration.test.ts
tests/charging-station/ocpp/1.6/OCPP16IncomingRequestService-RemoteStartTransaction.test.ts
tests/charging-station/ocpp/1.6/OCPP16IncomingRequestService-SmartCharging.test.ts
tests/charging-station/ocpp/1.6/OCPP16IncomingRequestService-TriggerMessage.test.ts
tests/charging-station/ocpp/1.6/OCPP16TestUtils.ts

index 208cabf05af1cf31311e2d0a2ed7e6650b80f01c..153c9573639f4a6b69f27f482ad437892cecf915 100644 (file)
@@ -595,14 +595,11 @@ export class OCPP16IncomingRequestService extends OCPPIncomingRequestService<OCP
                   .catch(errorHandler)
               }
             } else {
-              for (
-                let connectorId = 1;
-                connectorId <= chargingStation.getNumberOfConnectors();
-                connectorId++
-              ) {
-                const connectorStatus = chargingStation.getConnectorStatus(connectorId)
+              for (const { connectorId, connectorStatus } of chargingStation.iterateConnectors(
+                true
+              )) {
                 if (
-                  connectorStatus?.transactionStarted === true &&
+                  connectorStatus.transactionStarted === true &&
                   connectorStatus.transactionId != null
                 ) {
                   const transactionId = convertToInt(connectorStatus.transactionId)
@@ -1019,12 +1016,8 @@ export class OCPP16IncomingRequestService extends OCPPIncomingRequestService<OCP
         chargingStation.getNumberOfRunningTransactions() > 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<OCP
         )
       }
       const allChargingProfiles: OCPP16ChargingProfile[] = []
-      for (let id = 0; id <= chargingStation.getNumberOfConnectors(); id++) {
-        if (chargingStation.hasConnector(id)) {
-          allChargingProfiles.push(
-            ...(getConnectorChargingProfiles(chargingStation, id) as OCPP16ChargingProfile[])
-          )
-        }
+      for (const { connectorId: aggregatedConnectorId } of chargingStation.iterateConnectors()) {
+        allChargingProfiles.push(
+          ...(getConnectorChargingProfiles(
+            chargingStation,
+            aggregatedConnectorId
+          ) as OCPP16ChargingProfile[])
+        )
       }
       if (isEmpty(allChargingProfiles)) {
         return OCPP16Constants.OCPP_RESPONSE_REJECTED
@@ -1490,18 +1484,13 @@ export class OCPP16IncomingRequestService extends OCPPIncomingRequestService<OCP
     commandPayload: RemoteStartTransactionRequest
   ): Promise<GenericResponse> {
     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
index b336fd20da64a5e79056f860629c1f93ea91ede7..f8a070597be6425a0171fbd7bf0223b7128db82b 100644 (file)
@@ -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)
   // ---------------------------------------------------------------------------
index 664dd19dd4d4d4103cf8f539063b9b83bcdd0156..42c3ca8764fc07b1e9e1e9cd14335e9da979e028 100644 (file)
@@ -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
index 51236ab2e1eeed029fa079b7dfaa5b31c2e67ca4..80f9cf21ea68662a2a248ca368781699905bfd68 100644 (file)
@@ -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)
+    })
   })
 })
index d05d45721ca37be1708697e4d0f081da7e290976..0e8972c8952d4c5292b2d55fbd8346021c649374 100644 (file)
@@ -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<typeof mock.fn> => {
+  const handler = mock.fn(async () => Promise.resolve({}))
+  ;(
+    station.ocppRequestService as unknown as {
+      requestHandler: (...args: unknown[]) => Promise<unknown>
+    }
+  ).requestHandler = handler
+  return handler
+}
+
+const meterValuesBroadcastConnectorIds = (handler: ReturnType<typeof mock.fn>): 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
index 4c2abf1c64df70cac6df0c1e6bce3740bb8e6899..65cd58749f13eeb88ad0c4d3c9d3464f4fa631f1 100644 (file)
@@ -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<number, EvseStatus> }).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<number, ConnectorStatus> }).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.