.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)
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,
)
}
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
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
*/
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'
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)
// ---------------------------------------------------------------------------
} from '../../../helpers/TestLifecycleHelpers.js'
import { TEST_ID_TAG } from '../../ChargingStationTestConstants.js'
import {
+ createOCPP16EvseBackedContext,
createOCPP16IncomingRequestTestContext,
createOCPP16ListenerStation,
+ createOCPP16NonContiguousConnectorsContext,
type OCPP16IncomingRequestTestContext,
} from './OCPP16TestUtils.js'
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
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
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,
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
// 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)
+ })
})
})
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
})
})
+ 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
import type {
ChargingStationInfo,
ConfigurationKey,
+ ConnectorStatus,
+ EvseStatus,
JsonObject,
OCPP16ChargingProfile,
OCPP16ChargingSchedulePeriod,
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,
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.
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.