]> Piment Noir Git Repositories - e-mobility-charging-stations-simulator.git/commitdiff
fix: preserve EVSE and connector IDs in configuration persistence
authorJérôme Benoit <jerome.benoit@sap.com>
Wed, 18 Mar 2026 22:00:00 +0000 (23:00 +0100)
committerJérôme Benoit <jerome.benoit@sap.com>
Wed, 18 Mar 2026 22:00:00 +0000 (23:00 +0100)
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
src/charging-station/Helpers.ts
src/types/ChargingStationConfiguration.ts
src/utils/ChargingStationConfigurationUtils.ts
tests/utils/ChargingStationConfigurationUtils.test.ts

index 75987af15a5a73e7b5fc10b09b4d19dba498e8cd..05040822a4794c81468a034bc1598c4b48e9c502 100644 (file)
@@ -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>(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>(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<number, ConnectorStatus>(
-            // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
-            evseStatusConfiguration.connectorsStatus!.map((connectorStatus, connectorId) => [
+            connEntries.map(([connectorId, connectorStatus]) => [
               connectorId,
               prepareConnectorStatus(connectorStatus),
             ])
index e86e738b163f06adbdd90f2ea37339cbbbcf95f8..dc5ec1da898daa1e6f519216e9cc4cd7a9db4c7a 100644 (file)
@@ -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,
index bd7bf905fe0c4f28a0e99cfb2359b9d19a117a37..6f6c8052678d76e45bc6aa145ce04324b2e76f02 100644 (file)
@@ -14,13 +14,13 @@ export type ChargingStationConfiguration =
     }
 
 export type EvseStatusConfiguration = Omit<EvseStatus, 'connectors'> & {
-  connectorsStatus?: ConnectorStatus[]
+  connectorsStatus?: [number, ConnectorStatus][] | ConnectorStatus[]
 }
 
 interface ConnectorsConfiguration {
-  connectorsStatus?: ConnectorStatus[]
+  connectorsStatus?: [number, ConnectorStatus][] | ConnectorStatus[]
 }
 
 interface EvsesConfiguration {
-  evsesStatus?: EvseStatusConfiguration[]
+  evsesStatus?: [number, EvseStatusConfiguration][] | EvseStatusConfiguration[]
 }
index 4c7af2991565061b280633676716f68ffed73cbb..c69af9d54c018540525523d062741b07dc83c33d 100644 (file)
@@ -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,
+    ]
   })
 }
index 0ea606448a185fc7d5c08094d40381cbe48b5d12..b3d6bafa12b95f0a51ad073612a8882c4f3f28c0 100644 (file)
@@ -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<number, ConnectorStatus>()
+      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<string, unknown>
+      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<string, unknown>
-      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<number, ConnectorStatus>()
+      evseConnectors.set(1, {
+        availability: AvailabilityType.Operative,
+        MeterValues: [],
+      } as ConnectorStatus)
+      evseConnectors.set(2, {
+        availability: AvailabilityType.Inoperative,
+        MeterValues: [],
+      } as ConnectorStatus)
+
+      const evse0Connectors = new Map<number, ConnectorStatus>()
+      evse0Connectors.set(0, {
+        availability: AvailabilityType.Operative,
+        MeterValues: [],
+      } as ConnectorStatus)
+
+      const evses = new Map<number, EvseStatus>()
+      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<number, EvseStatus>()
+      evses.set(0, {
+        availability: AvailabilityType.Operative,
+        connectors: new Map<number, ConnectorStatus>(),
+      })
+      evses.set(3, {
+        availability: AvailabilityType.Inoperative,
+        connectors: new Map<number, ConnectorStatus>(),
+      })
+
+      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 () => {