From 7040dac859f4bfe0c6c2567e829b38c752454142 Mon Sep 17 00:00:00 2001 From: =?utf8?q?J=C3=A9r=C3=B4me=20Benoit?= Date: Wed, 18 Mar 2026 23:00:00 +0100 Subject: [PATCH] fix: preserve EVSE and connector IDs in configuration persistence MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit Serialization (buildConnectorsStatus, buildEvsesStatus) now saves [id, status] tuples instead of flat arrays, preserving EVSE and connector IDs per OCPP 2.0.1 §7. Deserialization detects both formats transparently: new tuple format uses explicit IDs, legacy flat array format falls back to array index. Add checkEvsesConfiguration template validation enforcing §7.2 connector numbering (EVSE 0: connector 0 only, EVSE ≥1: connectors start at 1). Add tests for ID preservation across serialization for both connectors and EVSEs. --- src/charging-station/ChargingStation.ts | 37 +++++- src/charging-station/Helpers.ts | 31 +++++ src/types/ChargingStationConfiguration.ts | 6 +- .../ChargingStationConfigurationUtils.ts | 58 +++++---- .../ChargingStationConfigurationUtils.test.ts | 112 ++++++++++++++++-- 5 files changed, 203 insertions(+), 41 deletions(-) diff --git a/src/charging-station/ChargingStation.ts b/src/charging-station/ChargingStation.ts index 75987af1..05040822 100644 --- a/src/charging-station/ChargingStation.ts +++ b/src/charging-station/ChargingStation.ts @@ -118,6 +118,7 @@ import { checkChargingStationState, checkConfiguration, checkConnectorsConfiguration, + checkEvsesConfiguration, checkStationInfoConnectorStatus, checkTemplate, createBootNotificationRequest, @@ -1571,6 +1572,9 @@ export class ChargingStation extends EventEmitter { if (stationTemplate.Connectors != null) { checkConnectorsConfiguration(stationTemplate, this.logPrefix(), this.templateFile) } + if (stationTemplate.Evses != null) { + checkEvsesConfiguration(stationTemplate, this.logPrefix(), this.templateFile) + } const stationInfo = stationTemplateToStationInfo(stationTemplate) stationInfo.hashId = getHashId(this.index, stationTemplate) stationInfo.templateIndex = this.index @@ -1864,21 +1868,46 @@ export class ChargingStation extends EventEmitter { private initializeConnectorsOrEvsesFromFile (configuration: ChargingStationConfiguration): void { if (configuration.connectorsStatus != null && configuration.evsesStatus == null) { - for (const [connectorId, connectorStatus] of configuration.connectorsStatus.entries()) { + const isTupleFormat = + configuration.connectorsStatus.length > 0 && + Array.isArray(configuration.connectorsStatus[0]) + const entries: [number, ConnectorStatus][] = isTupleFormat + ? (configuration.connectorsStatus as [number, ConnectorStatus][]) + : (configuration.connectorsStatus as ConnectorStatus[]).map((status, index) => [ + index, + status, + ]) + for (const [connectorId, connectorStatus] of entries) { this.connectors.set( connectorId, prepareConnectorStatus(clone(connectorStatus)) ) } } else if (configuration.evsesStatus != null && configuration.connectorsStatus == null) { - for (const [evseId, evseStatusConfiguration] of configuration.evsesStatus.entries()) { + const isTupleFormat = + configuration.evsesStatus.length > 0 && Array.isArray(configuration.evsesStatus[0]) + const evseEntries: [number, EvseStatusConfiguration][] = isTupleFormat + ? (configuration.evsesStatus as [number, EvseStatusConfiguration][]) + : (configuration.evsesStatus as EvseStatusConfiguration[]).map((status, index) => [ + index, + status, + ]) + for (const [evseId, evseStatusConfiguration] of evseEntries) { const evseStatus = clone(evseStatusConfiguration) delete evseStatus.connectorsStatus + const connIsTupleFormat = + evseStatusConfiguration.connectorsStatus != null && + evseStatusConfiguration.connectorsStatus.length > 0 && + Array.isArray(evseStatusConfiguration.connectorsStatus[0]) + const connEntries: [number, ConnectorStatus][] = connIsTupleFormat + ? (evseStatusConfiguration.connectorsStatus as [number, ConnectorStatus][]) + : ((evseStatusConfiguration.connectorsStatus ?? []) as ConnectorStatus[]).map( + (status, index) => [index, status] + ) this.evses.set(evseId, { ...(evseStatus as EvseStatus), connectors: new Map( - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - evseStatusConfiguration.connectorsStatus!.map((connectorStatus, connectorId) => [ + connEntries.map(([connectorId, connectorStatus]) => [ connectorId, prepareConnectorStatus(connectorStatus), ]) diff --git a/src/charging-station/Helpers.ts b/src/charging-station/Helpers.ts index e86e738b..dc5ec1da 100644 --- a/src/charging-station/Helpers.ts +++ b/src/charging-station/Helpers.ts @@ -418,6 +418,37 @@ export const checkConnectorsConfiguration = ( } } +export const checkEvsesConfiguration = ( + stationTemplate: ChargingStationTemplate, + logPrefix: string, + templateFile: string +): void => { + if (stationTemplate.Evses == null) { + return + } + for (const evseKey in stationTemplate.Evses) { + const evseId = convertToInt(evseKey) + const connectorIds = Object.keys(stationTemplate.Evses[evseKey].Connectors).map(convertToInt) + if (evseId === 0) { + for (const connectorId of connectorIds) { + if (connectorId !== 0) { + throw new BaseError( + `${logPrefix} Template ${templateFile} EVSE 0 has invalid connector id ${connectorId.toString()}, only connector id 0 is allowed (OCPP 2.0.1 §7.2)` + ) + } + } + } else if (evseId > 0) { + for (const connectorId of connectorIds) { + if (connectorId < 1) { + throw new BaseError( + `${logPrefix} Template ${templateFile} EVSE ${evseId.toString()} has invalid connector id ${connectorId.toString()}, connector ids must start at 1 (OCPP 2.0.1 §7.2)` + ) + } + } + } + } +} + export const checkStationInfoConnectorStatus = ( connectorId: number, connectorStatus: ConnectorStatus, diff --git a/src/types/ChargingStationConfiguration.ts b/src/types/ChargingStationConfiguration.ts index bd7bf905..6f6c8052 100644 --- a/src/types/ChargingStationConfiguration.ts +++ b/src/types/ChargingStationConfiguration.ts @@ -14,13 +14,13 @@ export type ChargingStationConfiguration = } export type EvseStatusConfiguration = Omit & { - connectorsStatus?: ConnectorStatus[] + connectorsStatus?: [number, ConnectorStatus][] | ConnectorStatus[] } interface ConnectorsConfiguration { - connectorsStatus?: ConnectorStatus[] + connectorsStatus?: [number, ConnectorStatus][] | ConnectorStatus[] } interface EvsesConfiguration { - evsesStatus?: EvseStatusConfiguration[] + evsesStatus?: [number, EvseStatusConfiguration][] | EvseStatusConfiguration[] } diff --git a/src/utils/ChargingStationConfigurationUtils.ts b/src/utils/ChargingStationConfigurationUtils.ts index 4c7af299..c69af9d5 100644 --- a/src/utils/ChargingStationConfigurationUtils.ts +++ b/src/utils/ChargingStationConfigurationUtils.ts @@ -47,14 +47,19 @@ export const buildConnectorEntries = (chargingStation: ChargingStation): Connect ) } -export const buildConnectorsStatus = (chargingStation: ChargingStation): ConnectorStatus[] => { - return [...chargingStation.connectors.values()].map( - ({ - transactionEventQueue, - transactionSetInterval, - transactionTxUpdatedSetInterval, - ...connectorStatus - }) => connectorStatus +export const buildConnectorsStatus = ( + chargingStation: ChargingStation +): [number, ConnectorStatus][] => { + return [...chargingStation.connectors.entries()].map( + ([ + connectorId, + { + transactionEventQueue, + transactionSetInterval, + transactionTxUpdatedSetInterval, + ...connectorStatus + }, + ]) => [connectorId, connectorStatus] ) } @@ -79,21 +84,28 @@ export const buildEvseEntries = (chargingStation: ChargingStation): EvseEntry[] })) } -export const buildEvsesStatus = (chargingStation: ChargingStation): EvseStatusConfiguration[] => { - return [...chargingStation.evses.values()].map(evseStatus => { - const connectorsStatus = [...evseStatus.connectors.values()].map( - ({ - transactionEventQueue, - transactionSetInterval, - transactionTxUpdatedSetInterval, - ...connectorStatus - }) => connectorStatus +export const buildEvsesStatus = ( + chargingStation: ChargingStation +): [number, EvseStatusConfiguration][] => { + return [...chargingStation.evses.entries()].map(([evseId, evseStatus]) => { + const connectorsStatus: [number, ConnectorStatus][] = [...evseStatus.connectors.entries()].map( + ([ + connectorId, + { + transactionEventQueue, + transactionSetInterval, + transactionTxUpdatedSetInterval, + ...connector + }, + ]) => [connectorId, connector] ) - const status: EvseStatusConfiguration = { - ...evseStatus, - connectorsStatus, - } - delete (status as { connectors?: unknown }).connectors - return status + const { connectors: _, ...evseStatusRest } = evseStatus + return [ + evseId, + { + ...evseStatusRest, + connectorsStatus, + } as EvseStatusConfiguration, + ] }) } diff --git a/tests/utils/ChargingStationConfigurationUtils.test.ts b/tests/utils/ChargingStationConfigurationUtils.test.ts index 0ea60644..b3d6bafa 100644 --- a/tests/utils/ChargingStationConfigurationUtils.test.ts +++ b/tests/utils/ChargingStationConfigurationUtils.test.ts @@ -79,12 +79,14 @@ await describe('ChargingStationConfigurationUtils', async () => { const result = buildConnectorsStatus(station) assert.strictEqual(result.length, 2) - for (const connector of result) { + for (const [, connector] of result) { assert.ok(!('transactionSetInterval' in connector)) assert.ok(!('transactionEventQueue' in connector)) assert.ok(!('transactionTxUpdatedSetInterval' in connector)) } - assert.strictEqual(result[1].availability, AvailabilityType.Operative) + assert.strictEqual(result[0][0], 0) + assert.strictEqual(result[1][0], 1) + assert.strictEqual(result[1][1].availability, AvailabilityType.Operative) } finally { clearInterval(interval1) clearInterval(interval2) @@ -114,9 +116,30 @@ await describe('ChargingStationConfigurationUtils', async () => { const result = buildConnectorsStatus(station) assert.strictEqual(result.length, 1) - assert.strictEqual(result[0].availability, AvailabilityType.Operative) - assert.strictEqual(result[0].transactionId, 42) - assert.strictEqual(result[0].transactionStarted, true) + assert.strictEqual(result[0][0], 1) + assert.strictEqual(result[0][1].availability, AvailabilityType.Operative) + assert.strictEqual(result[0][1].transactionId, 42) + assert.strictEqual(result[0][1].transactionStarted, true) + }) + + await it('should preserve non-sequential connector IDs', () => { + const connectors = new Map() + connectors.set(0, { + availability: AvailabilityType.Operative, + MeterValues: [], + } as ConnectorStatus) + connectors.set(3, { + availability: AvailabilityType.Inoperative, + MeterValues: [], + } as ConnectorStatus) + + const station = createMockStationForConfigUtils({ connectors }) + const result = buildConnectorsStatus(station) + + assert.strictEqual(result.length, 2) + assert.strictEqual(result[0][0], 0) + assert.strictEqual(result[1][0], 3) + assert.strictEqual(result[1][1].availability, AvailabilityType.Inoperative) }) }) @@ -145,9 +168,13 @@ await describe('ChargingStationConfigurationUtils', async () => { const result = buildEvsesStatus(station) assert.strictEqual(result.length, 2) - const evse1 = result[1] as Record + assert.strictEqual(result[1][0], 1) + const evse1 = result[1][1] assert.ok('connectorsStatus' in evse1) assert.ok(!('connectors' in evse1)) + const connectorsStatus = evse1.connectorsStatus as [number, ConnectorStatus][] + assert.strictEqual(connectorsStatus.length, 1) + assert.strictEqual(connectorsStatus[0][0], 1) }) await it('should strip internal fields from evse connectors', () => { @@ -168,13 +195,56 @@ await describe('ChargingStationConfigurationUtils', async () => { const station = createMockStationForConfigUtils({ evses }) const result = buildEvsesStatus(station) - const evse1 = result[0] as Record - const connectorsStatus = evse1.connectorsStatus as ConnectorStatus[] + const evse1 = result[0][1] + const connectorsStatus = evse1.connectorsStatus as [number, ConnectorStatus][] assert.strictEqual(connectorsStatus.length, 1) - assert.ok(!('transactionSetInterval' in connectorsStatus[0])) - assert.ok(!('transactionEventQueue' in connectorsStatus[0])) - assert.ok(!('transactionTxUpdatedSetInterval' in connectorsStatus[0])) + assert.strictEqual(connectorsStatus[0][0], 1) + const connector = connectorsStatus[0][1] + assert.ok(!('transactionSetInterval' in connector)) + assert.ok(!('transactionEventQueue' in connector)) + assert.ok(!('transactionTxUpdatedSetInterval' in connector)) + }) + + await it('should preserve connector IDs across serialization', () => { + const evseConnectors = new Map() + evseConnectors.set(1, { + availability: AvailabilityType.Operative, + MeterValues: [], + } as ConnectorStatus) + evseConnectors.set(2, { + availability: AvailabilityType.Inoperative, + MeterValues: [], + } as ConnectorStatus) + + const evse0Connectors = new Map() + evse0Connectors.set(0, { + availability: AvailabilityType.Operative, + MeterValues: [], + } as ConnectorStatus) + + const evses = new Map() + evses.set(0, { + availability: AvailabilityType.Operative, + connectors: evse0Connectors, + }) + evses.set(1, { + availability: AvailabilityType.Operative, + connectors: evseConnectors, + }) + + const station = createMockStationForConfigUtils({ evses }) + const result = buildEvsesStatus(station) + + const evse0Status = result[0][1].connectorsStatus as [number, ConnectorStatus][] + assert.ok(evse0Status.length > 0) + assert.strictEqual(evse0Status[0][0], 0) + + const evse1Status = result[1][1].connectorsStatus as [number, ConnectorStatus][] + assert.ok(evse1Status.length > 1) + assert.strictEqual(evse1Status[0][0], 1) + assert.strictEqual(evse1Status[1][0], 2) + assert.strictEqual(evse1Status[1][1].availability, AvailabilityType.Inoperative) }) await it('should handle empty evses map', () => { @@ -182,6 +252,26 @@ await describe('ChargingStationConfigurationUtils', async () => { const result = buildEvsesStatus(station) assert.strictEqual(result.length, 0) }) + + await it('should preserve non-sequential evse IDs', () => { + const evses = new Map() + evses.set(0, { + availability: AvailabilityType.Operative, + connectors: new Map(), + }) + evses.set(3, { + availability: AvailabilityType.Inoperative, + connectors: new Map(), + }) + + const station = createMockStationForConfigUtils({ evses }) + const result = buildEvsesStatus(station) + + assert.strictEqual(result.length, 2) + assert.strictEqual(result[0][0], 0) + assert.strictEqual(result[1][0], 3) + assert.strictEqual(result[1][1].availability, AvailabilityType.Inoperative) + }) }) await describe('buildChargingStationAutomaticTransactionGeneratorConfiguration', async () => { -- 2.43.0