From: Jérôme Benoit Date: Mon, 27 Jul 2026 12:54:30 +0000 (+0200) Subject: fix(ui-server): reconcile evses wire type so /metrics works on OCPP 2.0.x (#2048) X-Git-Tag: cli@v4.11.0~2 X-Git-Url: https://git.piment-noir.org/?a=commitdiff_plain;h=afdf433bacac02c03fd966a60e13b35d362547c7;p=e-mobility-charging-stations-simulator.git fix(ui-server): reconcile evses wire type so /metrics works on OCPP 2.0.x (#2048) * 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` 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. --- diff --git a/src/charging-station/ui-server/AbstractUIServer.ts b/src/charging-station/ui-server/AbstractUIServer.ts index 635f6ac6..e6cd8277 100644 --- a/src/charging-station/ui-server/AbstractUIServer.ts +++ b/src/charging-station/ui-server/AbstractUIServer.ts @@ -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 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 MeterValues?: SampledValueTemplate[] diff --git a/src/types/index.ts b/src/types/index.ts index bfb436ee..e832da5d 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -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' diff --git a/src/utils/ChargingStationConfigurationUtils.ts b/src/utils/ChargingStationConfigurationUtils.ts index cc2f516c..cb73c545 100644 --- a/src/utils/ChargingStationConfigurationUtils.ts +++ b/src/utils/ChargingStationConfigurationUtils.ts @@ -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 = ( diff --git a/tests/charging-station/ui-server/UIMetricsEndpoint.test.ts b/tests/charging-station/ui-server/UIMetricsEndpoint.test.ts index e1021f92..1da1fbd0 100644 --- a/tests/charging-station/ui-server/UIMetricsEndpoint.test.ts +++ b/tests/charging-station/ui-server/UIMetricsEndpoint.test.ts @@ -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): ChargingStationData['evses'] => { + const stub: Pick = { + * 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([ + [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([ + [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([[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) diff --git a/tests/utils/ChargingStationConfigurationUtils.test.ts b/tests/utils/ChargingStationConfigurationUtils.test.ts index b9a8aa69..236a325c 100644 --- a/tests/utils/ChargingStationConfigurationUtils.test.ts +++ b/tests/utils/ChargingStationConfigurationUtils.test.ts @@ -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))