]> Piment Noir Git Repositories - e-mobility-charging-stations-simulator.git/commitdiff
fix(ui-server): reconcile evses wire type so /metrics works on OCPP 2.0.x (#2048)
authorJérôme Benoit <jerome.benoit@piment-noir.org>
Mon, 27 Jul 2026 12:54:30 +0000 (14:54 +0200)
committerGitHub <noreply@github.com>
Mon, 27 Jul 2026 12:54:30 +0000 (14:54 +0200)
* fix(ui-server): reconcile evses wire type so /metrics works on OCPP 2.0.x

The worker->main producer `buildEvseEntries` emits `evseStatus.connectors`
as a `ConnectorEntry[]` array, but `ChargingStationData.evses` was typed
with the in-memory `EvseStatus` (connectors: Map), hidden by an
`as unknown as EvseEntry[]` cast. On a live scrape the metrics consumer
`iterateConnectors` tuple-destructured each array element as a Map entry,
throwing `TypeError: ... is not iterable` inside prom-client's
`Registry.metrics()` -> HTTP 500 for every OCPP 2.0.x station.
`countConnectors` also read `.size` on the array -> NaN. OCPP 1.6 was
unaffected because its `data.connectors` path already yields ConnectorEntry
objects without destructuring.

Introduce dedicated wire types `EvseEntryData`/`EvseStatusData`
(connectors: ConnectorEntry[]), type `ChargingStationData.evses` and
`buildEvseEntries` with them, and drop the cast so the compiler enforces
the array shape end to end. `iterateConnectors` now yields the entry
directly and `countConnectors` uses `.length`. The array shape matches what
the Web UI and CLI already consume over the JSON wire; a Map would not
survive JSON serialization. The in-memory `EvseStatus` (connectors: Map)
is unchanged.

Add a regression test feeding the actual `buildEvseEntries` output through
the /metrics scrape (fails pre-fix with 500/NaN, passes post-fix) plus
zero-id and empty-evses edge cases, and migrate the existing EVSE-mode test
off its synthetic Map fixture that masked the defect.

Closes #2046

* docs(types): document EvseEntryData/EvseStatusData wire projection

Add JSDoc on the wire types introduced for #2046:
- EvseEntryData: anchor its relation to the in-memory EvseEntry (Map) and to
  the structurally-identical ui-common EvseEntry (duplicated, no cross-package
  re-export), removing the naming ambiguity.
- EvseStatusData: document that MeterValues is intentionally omitted (the
  producer never emits it, no UI-facing consumer reads it off the wire),
  resolving the PR review comment on the omission.

Doc-only; no type or runtime change.

* test(ui-server): harden #2046 metrics regression guards

Address review findings on the #2046 regression tests:
- Convert the "empty evses" test to a populated EVSE with an empty connectors
  array. The zero-EVSE case short-circuits `reduce` (returns 0 even pre-fix),
  so it guarded nothing; a populated EVSE with `connectors: []` is the case
  that pre-fix reads `.size` on an array -> undefined -> NaN. Verified: fails
  pre-fix (connectors_total Nan), passes post-fix (0).
- Type the buildEvseEntries stub as `Pick<ChargingStation, 'iterateEvses'>`
  instead of `as unknown as`, so `iterateEvses`/`evseStatus` stay type-checked
  (resolves the PR review comment on the stub cast).
- Use `satisfies` instead of `as` on the migrated EVSE-mode fixture, so a
  Map/array shape drift is caught at compile time (the unchecked-cast class
  that masked the original bug).

* refactor(types): make EvseStatusData an immutable wire snapshot

Mark EvseStatusData.availability and connectors readonly (and connectors a
readonly ConnectorEntry[]), matching the readonly EvseEntryData wrapper. The
wire projection is a produced-once snapshot never mutated by any consumer
(countConnectors reads .length, iterateConnectors iterates read-only), so
immutability is the accurate contract and removes the wrapper/payload
readonly asymmetry. In-memory EvseStatus (mutable Map) is unchanged.

src/charging-station/ui-server/AbstractUIServer.ts
src/types/ChargingStationWorker.ts
src/types/Evse.ts
src/types/index.ts
src/utils/ChargingStationConfigurationUtils.ts
tests/charging-station/ui-server/UIMetricsEndpoint.test.ts
tests/utils/ChargingStationConfigurationUtils.test.ts

index 635f6ac67d51d550ae15b5814430ac26398f2cd5..e6cd8277d5fe1e186d829addb3cce7cbca71bf7e 100644 (file)
@@ -1539,7 +1539,7 @@ const addPerStationStatusInfo = (
 const countConnectors = (data: ChargingStationData): number =>
   isNotEmptyArray(data.connectors)
     ? data.connectors.length
-    : data.evses.reduce((n, evse) => n + evse.evseStatus.connectors.size, 0)
+    : data.evses.reduce((n, evse) => n + evse.evseStatus.connectors.length, 0)
 
 /**
  * Iterate connectors under the same OCPP 1.6 vs OCPP 2.0.x source split as
@@ -1557,8 +1557,8 @@ const iterateConnectors = function * (data: ChargingStationData): Generator<Conn
     return
   }
   for (const evse of data.evses) {
-    for (const [connectorId, connectorStatus] of evse.evseStatus.connectors) {
-      yield { connectorId, connectorStatus, evseId: evse.evseId }
+    for (const entry of evse.evseStatus.connectors) {
+      yield entry
     }
   }
 }
index 7d35962223adf95230227be79f21fa4129f8eff7..ede39f3b5037e1db7f155fa5c331c616a52cd661 100644 (file)
@@ -8,7 +8,7 @@ import type {
 import type { ChargingStationInfo } from './ChargingStationInfo.js'
 import type { ChargingStationOcppConfiguration } from './ChargingStationOcppConfiguration.js'
 import type { ConnectorEntry } from './ConnectorStatus.js'
-import type { EvseEntry } from './Evse.js'
+import type { EvseEntryData } from './Evse.js'
 import type { JsonObject } from './JsonType.js'
 import type { BootNotificationResponse } from './ocpp/Responses.js'
 import type { Statistics } from './Statistics.js'
@@ -34,7 +34,7 @@ export interface ChargingStationData extends WorkerData {
   automaticTransactionGenerator?: ATGConfiguration
   bootNotificationResponse?: BootNotificationResponse
   connectors: ConnectorEntry[]
-  evses: EvseEntry[]
+  evses: EvseEntryData[]
   ocppConfiguration: ChargingStationOcppConfiguration
   started: boolean
   stationInfo: ChargingStationInfo
index 1421a20b99b3a42900761da8fea4dbafb1776aaa..680785bde3c29915178e7e631a78fcf0b365ac1b 100644 (file)
@@ -1,4 +1,4 @@
-import type { ConnectorStatus } from './ConnectorStatus.js'
+import type { ConnectorEntry, ConnectorStatus } from './ConnectorStatus.js'
 import type { SampledValueTemplate } from './MeasurandPerPhaseSampledValueTemplates.js'
 import type { AvailabilityType } from './ocpp/Requests.js'
 
@@ -7,12 +7,33 @@ export interface EvseEntry {
   readonly evseStatus: EvseStatus
 }
 
+/**
+ * JSON-wire projection of {@link EvseEntry} carried by `ChargingStationData.evses`;
+ * `evseStatus.connectors` is a serialization-safe `ConnectorEntry[]`, whereas the
+ * in-memory {@link EvseEntry} keeps a `Map`. Mirrors the ui-common `EvseEntry` wire
+ * shape (duplicated because packages share no re-exports).
+ */
+export interface EvseEntryData {
+  readonly evseId: number
+  readonly evseStatus: EvseStatusData
+}
+
 export interface EvseStatus {
   availability: AvailabilityType
   connectors: Map<number, ConnectorStatus>
   MeterValues?: SampledValueTemplate[]
 }
 
+/**
+ * JSON-wire projection of {@link EvseStatus}: `connectors` is a `ConnectorEntry[]`
+ * (a `Map` serializes to `{}`). `MeterValues` is intentionally omitted — the producer
+ * `buildEvseEntries` never emits it and no UI-facing consumer reads it off the wire.
+ */
+export interface EvseStatusData {
+  readonly availability: AvailabilityType
+  readonly connectors: readonly ConnectorEntry[]
+}
+
 export interface EvseTemplate {
   Connectors: Record<string, ConnectorStatus>
   MeterValues?: SampledValueTemplate[]
index bfb436ee54113126e6ab7bc0b84e7049c61ac06d..e832da5d646bbf7fbf5d31925b9ae65dd54e8093 100644 (file)
@@ -47,7 +47,7 @@ export {
 export type { ConnectorEntry, ConnectorStatus } from './ConnectorStatus.js'
 export type { EmptyObject } from './EmptyObject.js'
 export type { HandleErrorParams } from './Error.js'
-export type { EvseEntry, EvseStatus, EvseTemplate } from './Evse.js'
+export type { EvseEntry, EvseEntryData, EvseStatus, EvseStatusData, EvseTemplate } from './Evse.js'
 export { FileType } from './FileType.js'
 export type { JsonObject, JsonType } from './JsonType.js'
 export { MapStringifyFormat } from './MapStringifyFormat.js'
index cc2f516c1c9f84c18b2d412cbf668063333ba1d6..cb73c5453efba764d51a1751d026f3f001f255f9 100644 (file)
@@ -4,7 +4,7 @@ import type {
   ChargingStationAutomaticTransactionGeneratorConfiguration,
   ConnectorEntry,
   ConnectorStatus,
-  EvseEntry,
+  EvseEntryData,
   EvseStatusConfiguration,
 } from '../types/index.js'
 
@@ -78,7 +78,7 @@ export const buildConnectorsStatus = (
     .toArray()
 }
 
-export const buildEvseEntries = (chargingStation: ChargingStation): EvseEntry[] => {
+export const buildEvseEntries = (chargingStation: ChargingStation): EvseEntryData[] => {
   return chargingStation
     .iterateEvses()
     .map(({ evseId, evseStatus }) => ({
@@ -99,7 +99,7 @@ export const buildEvseEntries = (chargingStation: ChargingStation): EvseEntry[]
         ),
       },
     }))
-    .toArray() as unknown as EvseEntry[]
+    .toArray()
 }
 
 export const buildEvsesStatus = (
index e1021f928352432a6e31ebaf55aeed5fc12875bb..1da1fbd03f178c5b6181b076cf0874d83615f552 100644 (file)
@@ -10,8 +10,10 @@ import type { Registry } from 'prom-client'
 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 {
   ChargingStationData,
+  EvseStatus,
   TemplateStatistics,
   UIServerConfiguration,
 } from '../../../src/types/index.js'
@@ -33,6 +35,7 @@ import {
   OCPP16AvailabilityType,
   OCPPVersion,
 } from '../../../src/types/index.js'
+import { buildEvseEntries } from '../../../src/utils/ChargingStationConfigurationUtils.js'
 import { logger } from '../../../src/utils/index.js'
 import { standardCleanup } from '../../helpers/TestLifecycleHelpers.js'
 import {
@@ -543,20 +546,20 @@ await describe('UIHttpServer /metrics endpoint (issue #851)', async () => {
             evseId: 1,
             evseStatus: {
               availability: OCPP16AvailabilityType.Operative,
-              connectors: new Map([
-                [
-                  1,
-                  {
+              connectors: [
+                {
+                  connectorId: 1,
+                  connectorStatus: {
                     availability: OCPP16AvailabilityType.Operative,
                     MeterValues: [],
                     status: ConnectorStatusEnum.Available,
                   },
-                ],
-              ]),
-              MeterValues: [],
+                  evseId: 1,
+                },
+              ],
             },
           },
-        ] as ChargingStationData['evses'],
+        ] satisfies ChargingStationData['evses'],
       })
     )
     server.mockListen(t)
@@ -580,6 +583,106 @@ await describe('UIHttpServer /metrics endpoint (issue #851)', async () => {
     assert.match(statusLine, /status="Available"/)
   })
 
+  // issue #2046: the earlier EVSE-mode test hand-builds a synthetic Map, so it
+  // never exercised the array shape the worker->main producer actually emits.
+  // These feed the REAL buildEvseEntries output into the scrape.
+  const buildEvseWireEntries = (evses: Map<number, EvseStatus>): ChargingStationData['evses'] => {
+    const stub: Pick<ChargingStation, 'iterateEvses'> = {
+      * iterateEvses () {
+        for (const [evseId, evseStatus] of evses) {
+          yield { evseId, evseStatus }
+        }
+      },
+    }
+    return buildEvseEntries(stub as ChargingStation)
+  }
+
+  const evseStatusOf = (
+    connectorIds: number[],
+    status = ConnectorStatusEnum.Available
+  ): EvseStatus => ({
+    availability: OCPP16AvailabilityType.Operative,
+    connectors: new Map(
+      connectorIds.map(connectorId => [
+        connectorId,
+        { availability: OCPP16AvailabilityType.Operative, MeterValues: [], status },
+      ])
+    ),
+  })
+
+  await it('should serve 200 with the ACTUAL buildEvseEntries wire shape and sum connectors across EVSEs (issue #2046)', async t => {
+    const evsesWire = buildEvseWireEntries(
+      new Map<number, EvseStatus>([
+        [1, evseStatusOf([1, 2])],
+        [2, evseStatusOf([1])],
+      ])
+    )
+    assert.ok(
+      Array.isArray(evsesWire[0].evseStatus.connectors),
+      'buildEvseEntries must emit evseStatus.connectors as an array (wire contract)'
+    )
+    server.addStation(buildStationData('station-2046', { connectors: [], evses: evsesWire }))
+    server.mockListen(t)
+
+    server.start()
+    const res = new MockServerResponse()
+    server.emitRequest(buildMetricsRequest(), res)
+    await awaitFinish(res)
+    assert.strictEqual(res.statusCode, 200)
+    const body = res.body ?? ''
+    assert.match(body, /^# HELP /m)
+    assert.match(body, /^# TYPE /m)
+    assert.match(body, /simulator_station_connectors_total\{[^}]*hash_id="station-2046"[^}]*\}\s+3/)
+    const statusLine = body
+      .split('\n')
+      .find(
+        l =>
+          l.startsWith('simulator_connector_status_info{') &&
+          l.includes('hash_id="station-2046"') &&
+          l.endsWith(' 1')
+      )
+    assert.ok(statusLine != null, 'simulator_connector_status_info value line not found')
+  })
+
+  await it('should count connector id 0 / evse id 0 in connectors_total (issue #2046)', async t => {
+    const evsesWire = buildEvseWireEntries(
+      new Map<number, EvseStatus>([
+        [0, evseStatusOf([0])],
+        [1, evseStatusOf([1])],
+      ])
+    )
+    server.addStation(buildStationData('station-2046-zero', { connectors: [], evses: evsesWire }))
+    server.mockListen(t)
+
+    server.start()
+    const res = new MockServerResponse()
+    server.emitRequest(buildMetricsRequest(), res)
+    await awaitFinish(res)
+    assert.strictEqual(res.statusCode, 200)
+    const body = res.body ?? ''
+    assert.match(
+      body,
+      /simulator_station_connectors_total\{[^}]*hash_id="station-2046-zero"[^}]*\}\s+2/
+    )
+  })
+
+  await it('should report connectors_total 0 (not NaN) for an EVSE with an empty connectors array (issue #2046)', async t => {
+    const evsesWire = buildEvseWireEntries(new Map<number, EvseStatus>([[1, evseStatusOf([])]]))
+    server.addStation(buildStationData('station-2046-empty', { connectors: [], evses: evsesWire }))
+    server.mockListen(t)
+
+    server.start()
+    const res = new MockServerResponse()
+    server.emitRequest(buildMetricsRequest(), res)
+    await awaitFinish(res)
+    assert.strictEqual(res.statusCode, 200)
+    const body = res.body ?? ''
+    assert.match(
+      body,
+      /simulator_station_connectors_total\{[^}]*hash_id="station-2046-empty"[^}]*\}\s+0/
+    )
+  })
+
   await it('should detect off-by-one at soft cap boundary (strict-greater-than semantics)', async t => {
     const warnSpy = t.mock.method(logger, 'warn', () => undefined)
 
index b9a8aa69a41126c0bdc8a98c5bb02e78d7b09e4c..236a325ca0b61fcd74f1f375d169e5698294c013 100644 (file)
@@ -9,7 +9,7 @@ import assert from 'node:assert/strict'
 import { afterEach, describe, it } from 'node:test'
 
 import type { ChargingStation } from '../../src/charging-station/index.js'
-import type { ConnectorEntry, ConnectorStatus, EvseStatus } from '../../src/types/index.js'
+import type { ConnectorStatus, EvseStatus } from '../../src/types/index.js'
 
 import { AvailabilityType } from '../../src/types/index.js'
 import {
@@ -512,7 +512,7 @@ await describe('ChargingStationConfigurationUtils', async () => {
   })
 
   await describe('buildEvseEntries', async () => {
-    await it('should return entries with evseId, evseStatus containing availability and connectors Map', () => {
+    await it('should return entries with evseId, evseStatus containing availability and connectors array', () => {
       const { station } = createMockChargingStation({
         connectorsCount: 1,
         evseConfiguration: { evsesCount: 1 },
@@ -545,9 +545,9 @@ await describe('ChargingStationConfigurationUtils', async () => {
       assert.strictEqual(result.length, 2)
       assert.strictEqual(result[0].evseId, 0)
       assert.strictEqual(result[0].evseStatus.availability, AvailabilityType.Operative)
-      assert.strictEqual((result[0].evseStatus.connectors as unknown as ConnectorEntry[]).length, 0)
+      assert.strictEqual(result[0].evseStatus.connectors.length, 0)
       assert.strictEqual(result[1].evseId, 1)
-      const connectors1 = result[1].evseStatus.connectors as unknown as ConnectorEntry[]
+      const connectors1 = result[1].evseStatus.connectors
       assert.strictEqual(connectors1.length, 1)
       assert.strictEqual(connectors1[0].connectorId, 1)
       assert.ok(!('transactionEndedMeterValues' in connectors1[0].connectorStatus))
@@ -600,7 +600,7 @@ await describe('ChargingStationConfigurationUtils', async () => {
       assert.strictEqual(result.length, 2)
       assert.strictEqual(result[0].evseId, 0)
       assert.strictEqual(result[1].evseId, 3)
-      const connectors3 = result[1].evseStatus.connectors as unknown as ConnectorEntry[]
+      const connectors3 = result[1].evseStatus.connectors
       assert.strictEqual(connectors3.length, 2)
       assert.ok(connectors3.some(c => c.connectorId === 2))
       assert.ok(connectors3.some(c => c.connectorId === 5))