]> Piment Noir Git Repositories - e-mobility-charging-stations-simulator.git/log
e-mobility-charging-stations-simulator.git
5 days agotest(ocpp-server): add OCPP 1.6 mock server and extend reservation coverage (#2047)
Jérôme Benoit [Mon, 27 Jul 2026 12:53:40 +0000 (14:53 +0200)] 
test(ocpp-server): add OCPP 1.6 mock server and extend reservation coverage (#2047)

* test(ocpp-server): add OCPP 1.6 mock server and extend reservation coverage

- Add server16.py: standalone OCPP 1.6 mock server mirroring server.py
  architecture (AuthConfig, ServerConfig, ChargePoint, on_connect, main)
- Add test_server16.py: 59 tests covering handlers, outgoing commands,
  signed meter values, timer, on_connect and main (~96% branch coverage)
- Add _send_reserve_now/_send_cancel_reservation to server.py (OCPP 2.0.1)
  with matching ServerConfig fields and DEFAULT_RESERVATION_* constants
- Extend test_server.py: tests for ReserveNow/CancelReservation commands,
  entries in EXPECTED_OUTGOING_COMMANDS and FAILURE_PATH_CASES, strengthen
  timestamp assertions (fromisoformat + tzinfo check)
- Update pyproject.toml: server16 run task, typecheck and coverage source
- Add tools/http_broadcast_timing.py and tools/reused_ws_request_id.py:
  ad-hoc diagnostic scripts for UI-server fixes #2037 and #2033

* chore(serena): update project knowledge base

* revert: remove tools/ from tracked files (kept locally)

* fix(ocpp-server): address review findings M1-M3 and m1-m8

M1 - test_boot_status_sequence_parsed: reuse _patch_main (add
**args_overrides), capture ServerConfig via side_effect, assert
boot_sequence == (pending, accepted)

M2 - DRY: extract check_positive_number to _common.py; update
typecheck task, coverage source, and mypy override to include it

M3 - README: rename to 'OCPP Mock Servers', add Running the Servers
section, add full OCPP 1.6 server documentation (20 CLI flags,
supported commands, transaction tracking)

m1 - remove dead DEFAULT_VENDOR_ID constant (server16.py)

m2 - align --reservation-id flag (server.py: --reserve-id ->
--reservation-id) and fix coupled Namespace in test_server.py

m3 - _call_and_log: log response.status on failure (server.py)

m4 - pyproject.toml: add test_server16 to mypy disallow_untyped_defs
override

m5 - add type hints to on_start_transaction, on_stop_transaction,
on_status_notification handlers (server16.py)

m6 - add test_windows_handler_schedules_via_call_soon_threadsafe
(test_server16.py)

m7 - document intentional 1.6 command scope in _COMMAND_HANDLERS

m8 - remove dead AuthMode.offline member (server.py)

INFO - boot_index: clarify shared-across-all-stations behavior
INFO - magic number 128: add Unix convention comment
INFO - argparse: 'OCPP2 Server' -> 'OCPP 2.0.1 Server' (server.py)
INFO - _parse_commands: raw_entry/entry variable naming (server.py)

Coverage tests: test_custom_auth_config, token vide x2,
test_meter_values_with_signed_meter_value (test_server16.py)

Quality gates: 335 passed, format/lint/typecheck clean, 94.78%

* fix(ocpp-server): address post-review findings N1-N6

N1/M2 - server16.py: import check_positive_number from _common, remove
local duplicate (567-576); _common.py now has a single consumer

N1/m1 - server16.py: remove dead DEFAULT_VENDOR_ID constant

N1/m5 - server16.py: add type hints to on_start_transaction
(connector_id, meter_start: int; id_tag: str), on_stop_transaction
(meter_stop, transaction_id: int), on_status_notification
(connector_id: int; error_code, status: str)

N1/m7 - server16.py: document intentional subset scope above
_COMMAND_HANDLERS with runtime behavior note

N1/INFO - server16.py: add Unix 128+signal comment before sys.exit;
expand boot_index one-liner to full NOTE (shared across ALL stations,
not per-station)

N2 - test_server.py: extend _patch_main with **args_overrides (same
pattern as test_server16.py); add test_boot_status_sequence_parsed in
TestMainGracefulShutdown — covers server.py:1134-1149

N3 - test_server16.py: add test_boot_status_single_value — covers
server16.py:786-787 (elif --boot-status single value branch)

N4 - pyproject.toml: update description to cover both 1.6 and 2.0.1

N5 - README.md: remove duplicated sentence at line 38

N6 - test_server16.py: fix RuntimeWarning (coroutine never awaited)
in test_commands_sequence_scheduled_on_connect — mock_cp.send_commands
was AsyncMock, its coroutine was passed to mocked create_task unrun;
replace with MagicMock() to prevent the orphan coroutine

Quality gates: 337 passed (0 warnings), format/lint/typecheck clean,
coverage 95.95% (+1.17pp)

* test(charging-station): relax template file count assertion

Replace strictEqual(15) with ok(count >= 15) so adding new station
templates does not require updating the hardcoded count.

* fix(ocpp-server): address round-3 review findings

MAJOR-1 - README.md 2.0.1: add ReserveNow and CancelReservation to
Outgoing Commands list; add --reservation-id, --reserve-id-token,
--reserve-evse-id flags to Command-specific options; add example command

MAJOR-2a - test_server.py: add caplog assertions to 4 signed MeterValue
tests (started/updated/ended/meter_values) — verify _log_signed_meter_values
actually logs, not just that the handler returns correct type

MAJOR-2b - test_server.py: rewrite test_unsupported_command_logs_warning
with caplog + assert 'not supported' in log output

MAJOR-2c - test_server.py: align 7 empty-response handlers on equality
pattern (== EmptyResult()) on top of isinstance — matches test_server16

MINOR-1 - _common.py: add generic parse_commands(str, type[ActionT])
factored from the two identical _parse_commands; both servers now use
a one-line wrapper; remove import math from server16/server (now in
_common only); coverage 96.76% (+0.81pp)

MINOR-2 - server.py: add intentional-subset scope comment above
_COMMAND_HANDLERS (mirrors server16.py:451-453)

MINOR-3 - server.py:342: (kwargs.get('evse') or {}).get('id', 0)
prevents AttributeError when payload carries evse=null explicitly;
add regression test test_transaction_event_started_null_evse_defaults_to_zero

MINOR-4 - test_server.py: port 3 missing tests from test_server16:
  - test_unexpected_error_is_caught (covers server.py:782-783)
  - test_commands_sequence_scheduled_on_connect (covers server.py:866)
  - test_boot_status_single_value via main() (covers server.py:1151)

MINOR-5 - README.md 1.6: enumerate valid --trigger-message types
(StatusNotification, BootNotification, Heartbeat, MeterValues,
FirmwareStatusNotification, DiagnosticsStatusNotification,
LogStatusNotification, SignChargePointCertificate)

Quality gates: 341 passed (0 warnings), format/lint/typecheck clean,
coverage 96.76% (+0.81pp from 95.95%)

* fix(ocpp-server): address round-4 review findings

MAJOR-A - README.md 2.0.1: replace invalid --trigger-message value
SignCertificate with the 11 exact MessageTriggerEnumType members
(SignChargingStationCertificate/SignV2GCertificate/SignCombinedCertificate
+ TransactionEvent + PublishFirmwareStatusNotification); the 1.6 list was
fixed in round-3, this is its 2.0.1 counterpart

MAJOR-B - test_server.py: add TestLogSignedMeterValues (3 tests) adapted
to 2.0.1 semantics (signed_meter_value field, not format=SignedData);
covers the previously-uncovered unsigned branch server.py:97->95

MINOR-C - test_server.py + test_server16.py: 6 error-handling tests
(timeout/ocpp_error/unexpected) upgraded from swallowing-only to caplog
assertions on level ERROR + message substring

MINOR-D - DRY: extract negotiate_subprotocol + install_signal_handlers_and_wait
to _common.py (version-agnostic lifecycle, logger passed explicitly to
preserve observability); both servers use them; remove import signal from
both servers; retarget 4 signal.signal patches to _common. Instance methods
(_call_and_log/_send_command/...) and DEFAULT_RESERVATION_* constants left
in place (OCPP coupling / locality cost > DRY gain for a test mock)

MINOR-E - _common.py to 100%: add non-numeric-delay and empty-entry tests
for _parse_commands in both suites

INFO MORT-1 - remove unreachable dead code (if not boot_sequence) in both
servers' main()

INFO-D - server.py: handle_connection_closed(self) -> None annotation

Quality gates: 348 passed (0 warnings), format/lint/typecheck clean,
coverage 97.51% (+0.75pp); _common.py 100%

* test(ocpp-server): close round-5 parity + config findings

MINOR parity - test_server.py: assert len(config.charge_points) == 0 on
both on_connect rejection tests (missing subprotocol / protocol mismatch),
mirroring test_server16.py; guards against a regression registering a
ChargePoint on a rejected connection in 2.0.1

MINOR parity - test_server.py: strengthen Heartbeat test_returns_current_time
with a freshness window (delta < 60s) + import timezone, mirroring the 1.6
suite (was isinstance + tzinfo only)

INFO I4 - pyproject.toml: mypy python_version 3.14 -> 3.12 to match the
requires-python floor (>=3.12); mypy stays clean (verified live)

INFO I5 - README.md: add the --boot-status shorthand sentence to the 1.6
section for parity with 2.0.1 (identical behavior)

Deliberately NOT changed (accepted debt, evidence-based):
- DRY mixin for send_command/send_commands/handle_connection_closed: moving
  them to _common would emit lifecycle logs under _common instead of
  server/server16 (silent observability regression) for ~24 lines in a mock
- mypy test override kept: real guard (test files carry untyped defs)
- docstrings on _common helpers: 0/4 documented, keeping style consistent

Quality gates: 348 passed, format/lint/typecheck clean (python 3.12),
coverage 97.51%

* test(ocpp-server): close round-6 parity + hygiene findings

MINOR-1 - test_server16.py: add test_parent_id_tag_absent_by_default,
mirroring 2.0.1 test_authorize_no_enrichment_by_default; asserts
parent_id_tag is absent from id_tag_info under the default AuthConfig
(closes the last inter-suite rigor asymmetry)

MINOR-2 - server.py: harmonize _call_and_log docstring with server16.py
(both now '...based on its status')

MINOR-3 - test_server.py: add test_parse_set_variable_specs_skips_empty_entries
and test_parse_get_variable_specs_invalid_no_dot, covering the two
previously-uncovered branches of _parse_variable_specs (empty-entry skip,
require_value=False missing-dot); server.py 875/885 now covered

I1 - server.py + server16.py: derive --auth-mode choices/default from the
local AuthMode StrEnum ([mode.value for mode in AuthMode] / AuthMode.normal.value)
instead of string literals (AGENTS.md: avoid string literals when an
enumeration exists); order/content identical (verified live), no behavior change

Quality gates: 351 passed, format/lint/typecheck clean (python 3.12),
coverage 97.88% (server.py 98%)

* test(ocpp-server): close round-7 style + DRY findings

- test_server.py: fix stale docstring (20 -> 24 _send_* methods)
- server.py/server16.py: --auth-mode type=str -> type=AuthMode to match
  the sibling enum-typed CLI args (--boot-status, --reset-type, ...); keep
  choices for unchanged help/UX
- server.py/server16.py: extract DEFAULT_WHITELIST/DEFAULT_BLACKLIST module
  constants (single source of truth; removes __init__/argparse duplication)
- test_server.py/test_server16.py: TestHandlerCoverage now asserts @on
  registration (_on_action) so an undecorated handler is caught

* test(ocpp-server): close round-8 findings (harmonize auth-mode, DRY, handler routing)

- _common.py: host DEFAULT_WHITELIST/DEFAULT_BLACKLIST as the single source
  of truth; server.py and server16.py now import them (removes cross-file
  literal duplication)
- server.py/server16.py: drop now-dead choices=list(AuthMode) from --auth-mode
  and move valid values into the help text, matching the sibling enum-typed
  args (--boot-status, --reset-type, ...); uniform invalid-value error message
- server.py/server16.py: mode=args.auth_mode (args value is already an AuthMode
  via type=AuthMode; drop redundant re-wrap)
- test_server.py/test_server16.py: TestHandlerCoverage now asserts each handler
  is registered for the CORRECT OCPP action (_on_action == expected), not merely
  decorated with some @on

* test(ocpp-server): address bot review comments on server16

- test_server16.py: use supported CSMS->CS commands (TriggerMessage/Reset)
  instead of ClearCache/Heartbeat in the send_commands sequencing and
  _parse_commands tests, so the fixtures reflect commands actually in
  _COMMAND_HANDLERS rather than unsupported outgoing actions
- server.py/server16.py: replace the empty `except KeyboardInterrupt: pass`
  with contextlib.suppress(KeyboardInterrupt) (clears the empty-except finding
  without a comment; both servers for parity)
- server.py/server16.py: _resolve_auth_status returns default_status via an
  unconditional trailing return instead of a wildcard `case _`, making the
  control flow unambiguous to static analysis (both servers for parity)

5 days agochore(deps): update all non-major dependencies (#2049)
renovate[bot] [Mon, 27 Jul 2026 11:35:03 +0000 (13:35 +0200)] 
chore(deps): update all non-major dependencies (#2049)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
7 days agofix(deps): update all non-major dependencies (#2045)
renovate[bot] [Sat, 25 Jul 2026 18:58:25 +0000 (20:58 +0200)] 
fix(deps): update all non-major dependencies (#2045)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
9 days agochore(deps): update actions/setup-python action to v7 (#2044)
renovate[bot] [Thu, 23 Jul 2026 15:34:36 +0000 (17:34 +0200)] 
chore(deps): update actions/setup-python action to v7 (#2044)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
9 days agochore(deps): update all non-major dependencies (#2043)
renovate[bot] [Thu, 23 Jul 2026 11:32:09 +0000 (13:32 +0200)] 
chore(deps): update all non-major dependencies (#2043)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
10 days agochore(deps): update actions/checkout digest to 3d3c42e (#2041)
renovate[bot] [Wed, 22 Jul 2026 12:57:01 +0000 (14:57 +0200)] 
chore(deps): update actions/checkout digest to 3d3c42e (#2041)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
10 days agofix(deps): update all non-major dependencies (#2042)
renovate[bot] [Wed, 22 Jul 2026 12:34:02 +0000 (14:34 +0200)] 
fix(deps): update all non-major dependencies (#2042)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
12 days agofeat(ui-server): add opt-in HSTS response header (#1980) (#2039)
Jérôme Benoit [Mon, 20 Jul 2026 10:52:13 +0000 (12:52 +0200)] 
feat(ui-server): add opt-in HSTS response header (#1980) (#2039)

Add an opt-in `uiServer.securityHeaders.strictTransportSecurity` config
knob (`string | false`) that, when set to a non-empty string, emits a
`Strict-Transport-Security` (HSTS) response header on the UI server.

Emission is gated on secure transport per RFC 6797 §7.2 (which forbids
sending HSTS over non-secure transport): the header is emitted only when
the response travels over direct TLS (`req.socket.encrypted`) or a trusted
reverse proxy that forwarded a secure protocol (`https`/`wss`), resolved by
a new `isRequestEffectivelySecure` predicate that reuses the access-policy
trusted-proxy/forwarded-protocol machinery. Over plaintext (e.g. loopback
development) the header is omitted.

The header is spread — via `getSecurityHeaders(isSecure)` and, for the
async HTTP success path, a per-uuid `secureResponses` map mirroring
`acceptsGzip` — across every UI-server HTTP response path that this code
writes: shared denials (`renderDenial`, now request-aware), HTTP success
responses (gzip and non-gzip), WebSocket upgrade rejections, and the
`/metrics` success and error responses, on the http/ws/mcp transports. MCP
JSON-RPC success bodies are written by the MCP SDK transport and are not
covered.

Mirrors the `metrics` opt-in sub-schema: one `.strict()` leaf schema, the
derived `z.infer` type, and header spreads. No new dependency; no OCPP
PDU/message-format change (HTTP transport header only).

The header is absent by default (knob unset, `false`, or empty string) and
over non-secure transport, so the default response behavior is unchanged.
Recommended production value behind a TLS-terminating reverse proxy or
native TLS: "max-age=31536000; includeSubDomains".

Closes only slice C of #1980; the identity-aware proxy mode, local-interface
spoofing guard, security audit CLI, and dangerously* naming slices remain open.

12 days agotest: use shared constants and enums instead of hardcoded literals (#2040)
Jérôme Benoit [Mon, 20 Jul 2026 10:50:38 +0000 (12:50 +0200)] 
test: use shared constants and enums instead of hardcoded literals (#2040)

Replace hardcoded literals in recent tests with the shared constants/enums they duplicate (single source of truth, no behavior change; values are byte-equivalent):

- OCPP20 PostStopResurrection: GenericStatus.Accepted / ReportBaseEnumType.FullInventory instead of raw 'Accepted' / 'FullInventory'
- OCPP20 RequestStartTransaction: OCPP20ChargingRateUnitEnumType.A instead of 'A' as OCPP20ChargingRateUnitEnumType casts
- OCPP16 SmartCharging: TEST_ONE_HOUR_SECONDS instead of the 3600 duration literal
- UIHttpServer: ProcedureName.* instead of raw procedure-name strings
- Remove the now-unused TEST_PROCEDURES test constant (ProcedureName is the canonical source)

13 days agofix(ui-server): defer HTTP broadcast responses until worker replies aggregate (#2028...
Jérôme Benoit [Sun, 19 Jul 2026 19:27:45 +0000 (21:27 +0200)] 
fix(ui-server): defer HTTP broadcast responses until worker replies aggregate (#2028) (#2037)

The deprecated HTTP UI transport emitted a synthetic `success` the instant the
request handler resolved `undefined` (the broadcast-pending signal), without
awaiting worker replies. PR #2020's Set-based aggregation therefore covered the
WebSocket and MCP transports but not HTTP: a fan-out command reported
`200`/`success` before any worker acted, and the aggregated
`hashIdsFailed`/`responsesFailed` never reached the client.

Align HTTP with the WebSocket and MCP transports (issue #2028 option 1): only
synchronous, non-broadcast procedures respond inline. A broadcast keeps its
already-registered response handler open so the later aggregated `sendResponse`
writes the real status and `hashIdsSucceeded`/`hashIdsFailed`, reusing the
existing 60s safety-net timeout (`UI_SERVER_BROADCAST_CHANNEL_REQUEST_TIMEOUT_MS`)
so a never-arriving aggregation yields a bounded failure with no hung socket.
Client-disconnect cleanup and server-side uuid minting are unchanged; no new
timeout or aggregation path is introduced.

This is a UI SRPC transport-layer fix only; no OCPP PDU/message format changes,
and no change to WebSocket/MCP behavior or the shared aggregation semantics.

13 days agofeat(worker): terminate a deleted station's worker once it hosts zero elements (...
Jérôme Benoit [Sun, 19 Jul 2026 19:17:18 +0000 (21:17 +0200)] 
feat(worker): terminate a deleted station's worker once it hosts zero elements (#2027) (#2038)

* feat(worker): terminate a deleted station's worker once it hosts zero elements (#2027)

Add an element-granular removeElement primitive to the worker abstraction and
wire it into the station-delete path so a deleted charging station's hosting
worker thread is terminated once it no longer hosts any station, without
disrupting sibling stations that share the worker (elementsPerWorker > 1).

- WorkerAbstract: new abstract removeElement(elementKey: PropertyKey).
- WorkerSet: internal PropertyKey -> WorkerSetElement map fed by an injected
  generic elementKey selector; decrement numberOfWorkerElements and reuse a
  factored terminateWorker helper (shared with stop()) to terminate only at
  zero elements with no in-flight addition; purge the map on element removal.
- WorkerFixedPool/WorkerDynamicPool: documented no-op (poolifier exposes no safe
  single-element eviction without destroying the worker and its siblings).
- Bootstrap: pass stationInfo => stationInfo.hashId; call removeElement from
  workerEventDeleted.

Set-vs-pool decision: element-granular termination is workerSet-only; pool
worker threads are a bounded, reused resource and are not reclaimed per delete.
Cleanup/timeout contract: termination reuses the existing worker exit handler
for pending-promise rejection and set/map cleanup; any in-flight broadcast
request still resolves via the existing 60s aggregation timeout backstop.
Part A (#2031 cancellable reset) and bulk stop() are untouched.

* fix(worker): harden element-granular termination against concurrency races

Cross-validated review of the removeElement primitive surfaced two reachable
defects and one latent robustness issue, now fixed:

- Phantom-count leak on re-adding the same element key: numberOfWorkerElements
  was incremented unconditionally, so a duplicate/re-added key inflated the
  count and the worker was never terminated. Count is now key-aware (distinct
  keys per worker); factored into trackAddedWorkerElement.
- New addition routed onto a terminating worker: getWorkerSetElement could
  select a worker parked in terminateWorker, losing the message and rejecting
  an unrelated add. Terminating workers are now flagged and excluded from
  selection.
- getWorkerSetElementByWorker matched by threadId, which collapses to -1 after
  terminate(); switched to worker object identity.

Tests: add coverage for the in-flight-sibling gate, drain-to-last-sibling,
add-during-terminate, same-key re-add, and unknown-key no-op; consolidate the
silent worker fixture into echoWorker via a `hold` flag. Mutation-verified.

* fix(worker): make element termination robust and tighten its types

Second cross-validated review round hardened the removeElement primitive:

- terminateWorker no longer awaits a re-attached 'exit' listener (redundant,
  since worker.terminate() fulfils once the worker has exited, and it could
  deadlock if 'exit' fired first). It now catches a rejected terminate()
  (surfaced via the error event, not rethrown so the removal still succeeds)
  and performs pending-promise rejection + set/map cleanup in a finally, closing
  the zombie-on-reject leak, the orphaned-pending edge, and the stop-concurrent-
  exit deadlock in one place.
- Drop the dead, semantically fictional migration branch in
  trackAddedWorkerElement: duplicate live element keys cannot occur (identities
  are deduplicated upstream), so a key is counted once per worker and a same-key
  re-add is a no-op.
- Make WorkerSetElement.terminating a required boolean (initialized false) and
  narrow the element key from PropertyKey to string, matching the sole caller
  (hashId) and avoiding non-serializable keys.

Tests: add reject-path coverage (removeElement resolves and cleans up when
terminate() rejects; an in-flight addition is still rejected on stop when
terminate() rejects). Mutation-verified. All project gates green.

* test(worker): align WorkerSet internals view element key type to string

Coherence with the production narrowing of the element key from PropertyKey
to string; test-only, no runtime change.

* docs(worker): drop the worker-termination note from the README

13 days agorefactor: code-quality cleanup — utils usage, logic consolidation, export hygiene...
Jérôme Benoit [Sun, 19 Jul 2026 16:17:58 +0000 (18:17 +0200)] 
refactor: code-quality cleanup — utils usage, logic consolidation, export hygiene (#2036)

* refactor(ui-server): use isEmpty for outstanding-hashid checks

* refactor(ui-server): extract releaseRequest for broadcast request release

* refactor(ocpp): lift requestHandler into base template method

* refactor(utils): add isOCPP20x helper and route version predicates

* refactor(charging-station): single-source the request-statistics gate

* refactor(utils): use isNotEmptyString/isNotEmptyArray predicates

* refactor: drop dead exports and redundant re-export

* refactor(auth): rename AuthorizationStatus enum to AuthResultStatus

* refactor(ocpp): drop now-redundant logRequestHandlerError module-name param

* docs(charging-station): document recordRequestStatistic public method

* refactor(ocpp): route VariableMetadata mutability/persistence checks through predicates

* refactor(ocpp): unexport in-file-only parseJsonSchemaFile

13 days agofix(ui-server): reject reused in-flight WebSocket request ids (#2029) (#2033)
Jérôme Benoit [Sun, 19 Jul 2026 11:33:19 +0000 (13:33 +0200)] 
fix(ui-server): reject reused in-flight WebSocket request ids (#2029) (#2033)

* fix(ui-server): reject reused in-flight WebSocket request ids (#2029)

Client-supplied WebSocket UI request ids were validated for format only.
A second request reusing a still-in-flight id overwrote the prior request's
response handler (cross-delivered reply, dropped second response) and its
broadcast tracking (leaked safety-net timer firing against the wrong context).

Guard the transport ingest: when responseHandlers already holds the request id,
reject the duplicate with a typed BaseError failure on its own socket instead of
overwriting the in-flight request. A completed request releases its handler, so a
legitimate sequential reuse of the same id is still accepted. HTTP and MCP mint
server-side UUIDs and are unaffected.

Closes #2029

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* fix(ui-server): harden in-flight duplicate rejection send

Wrap the ws.send in rejectInFlightRequestId() in try/catch, matching the
sendResponse() send discipline. The helper runs in the synchronous 'message'
listener, so an uncaught send throw would escape unhandled; log and swallow it
instead. No behavior change to the in-flight guard.

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* test(ui-server): fail parseSentResponse with a descriptive assertion

When no message is captured at the requested index, fail with an explicit
assertion naming the index and sentMessages length instead of letting
JSON.parse throw an opaque SyntaxError. Use a length check rather than a
nullish guard, since MockWebSocket.sentMessages is typed string[] (indexed
access is not nullable under the project's tsconfig).

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* test(ui-server): reuse shared UI-server test helpers and constants

Hoist emitWorkerResponse into UIServerTestUtils as the single source of truth
and consume it from both AbstractUIService and UIWebSocketServer tests, removing
the duplicated worker-response injection helper (with divergent argument order).
In the WebSocket test, build the protocol request via createProtocolRequest
instead of an inline tuple, and drop the redundant explicit 'ui0.0.1' argument
(it is createMockUIWebSocket's default).

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
---------

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
13 days agofix(deps): update all non-major dependencies (#2034)
renovate[bot] [Sun, 19 Jul 2026 11:08:48 +0000 (13:08 +0200)] 
fix(deps): update all non-major dependencies (#2034)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2 weeks agofix(ui-server): dedup duplicate station identity on add (#2026) (#2032)
Jérôme Benoit [Sat, 18 Jul 2026 21:32:20 +0000 (23:32 +0200)] 
fix(ui-server): dedup duplicate station identity on add (#2026) (#2032)

* fix(ui-server): dedup duplicate station identity on add (#2026)

hashId is a deterministic content hash of template identity fields + composed
chargingStationId, with no uniqueness check on add. Two stations from a
template with identical identity fields (e.g. fixedName: true) collide on one
hashId. AbstractUIServer.setChargingStationData was last-writer-wins by
timestamp keyed by hashId, so the twin silently overwrote the first station in
the UI registry; a targeted broadcast command then completed success on the
first worker's reply while the collided twin was orphaned.

Add post-creation identity dedup at the registry write choke point:
setChargingStationData returns a discriminated 'set' | 'stale' | 'collision'
outcome. A write whose hashId already maps to a station with a different
templateIndex is rejected as 'collision' without overwriting, guarding every
worker event (added/started/stopped/updated) uniformly. A restart re-emit
reuses the same templateIndex and still updates by timestamp, so there is no
regression. workerEventAdded logs the rejected collision at error level
instead of silently reporting the twin as added.

getHashId output is unchanged (byte-identical), so persisted <hashId>.json
configuration, the registry map key, broadcast routing and stats resolve
untouched. No OCPP PDU / message-format change; simulator station-registry
lifecycle only.

Tests: a twin (same hashId, different templateIndex) is rejected and does not
overwrite; a restart re-emit (same identity, newer timestamp) still updates; a
stale same-identity re-emit is dropped; a targeted broadcast reports no false
success after a collision. Mutation-verified: reverting the guard fails the
twin/false-success tests while the restart/stale tests stay green.

Duplicate-identity aggregation rework and hash-algorithm / per-instance-salt
changes are out of scope (per issue). Causally linked to #2027: an orphaned
twin is exactly the worker #2027 cannot individually terminate.

Closes #2026.

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* fix(ui-server): discriminate station identity by (templateName, templateIndex) (#2026)

Strengthen the add-time identity dedup after a multi-reviewer cross-validation.
The initial guard discriminated on templateIndex alone, but getHashId does not
hash the template file name and the index space is per-templateName: two
identity-clone template files (copy + rename, both fixedName, same baseName and
identity fields) can produce the same hashId AND the same templateIndex. The
templateIndex-only guard treated them as the same station and silently
overwrote one, reintroducing the last-writer-wins bug for cross-template
collisions.

Discriminate on the full physical-station identity, the (templateName,
templateIndex) pair: a cached entry differing in either field is a collision. A
legitimate restart re-emit keeps both fields and still updates by timestamp (no
regression). Sync the SetChargingStationDataOutcome doc to the pair, add
templateName to the rejection log so an equal-index clone collision is
diagnosable, and add a test for the same-templateIndex / different-templateName
case.

Mutation-verified: reverting the templateName clause fails only the new
clone-file test while the same-template twin, restart, stale, and
false-success tests stay green.

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
---------

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2 weeks agofix(charging-station): cancel a pending reset when the station is deleted (#2027...
Jérôme Benoit [Sat, 18 Jul 2026 21:31:40 +0000 (23:31 +0200)] 
fix(charging-station): cancel a pending reset when the station is deleted (#2027) (#2031)

reset() stopped the station, slept resetTime, then re-initialized and
reconnected without checking whether the station had been deleted during
the sleep. A station deleted mid-reset was resurrected and reconnected to
the CSMS (the "zombie" reconnect that triggers #2017).

Add an instance AbortController tripped by delete(); reset() now awaits
interruptibleSleep(resetTime, signal) and rechecks the aborted state
before re-initializing, bailing out cleanly on abort. The dispose signal
is kept distinct from started/stopping, which reset() itself toggles via
stop() and therefore cannot be used to detect deletion.

The OCPP Reset response stays Accepted and the handler still invokes
reset() fire-and-forget; only the reset/delete lifecycle changes.

Part B (element-granular worker-thread termination) remains open.

2 weeks agofix(deps): update all non-major dependencies (#2030)
renovate[bot] [Sat, 18 Jul 2026 14:37:12 +0000 (16:37 +0200)] 
fix(deps): update all non-major dependencies (#2030)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2 weeks agofix(ui-server): make UI broadcast-channel commands complete reliably (#2018) (#2020)
Daniel [Fri, 17 Jul 2026 22:17:54 +0000 (00:17 +0200)] 
fix(ui-server): make UI broadcast-channel commands complete reliably (#2018) (#2020)

* fix(ui-server): time out broadcast-channel requests so UI commands cannot hang

A UI control command (start/stop/delete a station) is dispatched over the
worker broadcast channel and the UI service waits for a fixed number of
worker responses, sampled at send time. If a targeted worker never replies
-- e.g. the station is deleted while the command is in flight -- that count
is never reached, so the request is never completed or released and the
client waits forever. Read commands keep working, which masks the wedge.

Arm a per-request safety-net timeout when a broadcast-channel request is
sent. On expiry the request is completed with a failure (reporting the
charging stations that did reply successfully) and both the request and
response aggregation state are released, so the caller gets an answer
instead of hanging. The timeout is cleared on normal completion, on a
dispatch failure, and on service stop.

Closes #2018.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Daniel <7558512+DerGenaue@users.noreply.github.com>
* fix(ui-server): complete broadcast requests by target set and reconcile on station deletion (#2018)

Broadcast-channel request completion was gated on a frozen integer count
sampled at send time, blind to which stations were targeted. When the set
of stations changed mid-flight the aggregator could not reconcile the
drift, producing failure modes on top of the hang the safety-net timeout
already backstops:

- Hang until timeout when a targeted station is deleted mid-flight (the
  count is never reached).
- Late double-completion: after release the count reads 0, so a late reply
  re-enters and 1 >= 0 re-completes the request.
- False success: an empty explicit hashIds array degraded to a
  broadcast-to-all instead of failing.

Track the outstanding responders as a Set of target hashIds and make the
Set the single source of truth for completion:

1. Snapshot the resolved targets (validated explicit hashIds, or the live
   station set for a broadcast) into the request context; complete when the
   Set empties. AbstractUIServer.deleteChargingStationData fans out
   AbstractUIService.reconcileDeletedStation, which drops the departed
   hashId from every in-flight request and completes any that empty with
   the truthful aggregated payload. A DELETE_CHARGING_STATIONS request is
   left untouched by reconciliation: its targets self-delete yet still post
   their command reply on a separate transport, so that reply (not the
   racing `deleted` event) is the completion source; a worker that dies
   mid-delete is covered by the timeout.
2. Drop untracked responses (unknown/released request, or a hashId that is
   not an outstanding responder) in the response handler, closing the late
   double-completion re-entry. A reply without a hashId for a still-tracked
   request is dropped and logged distinctly so the resulting timeout is
   diagnosable.
3. Reject unknown and empty explicit targets instead of degrading to a
   broadcast-to-all.

The 60 s safety-net timeout stays as a pure backstop for genuine worker
crash/deadlock; normal and reconciled completion clear its timer. The
completion accessor is named getBroadcastChannelOutstandingResponseCount to
reflect that it returns the responses still outstanding, not a frozen total.

Duplicate-identity false success and orphan-worker termination are not
addressed here (a hashId-keyed set cannot disambiguate two workers sharing
an identity) and are deferred to follow-up changes. The deprecated HTTP
transport bypasses aggregation and is likewise out of scope.

Tests cover reconcile-on-delete, the untracked-response guard, unknown and
empty target rejection, the DELETE self-target ordering (reconcile-first
completes via the reply; no reply falls back to the timeout), reconcile
that does not empty the set, two concurrent requests reconciled by one
deletion, a hashId-less reply, and the previously untested timeout
behaviors (partial-success payload, dispatch-failure and stop() timer
clearing, and the completion-race guard).

Closes #2018.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
---------

Signed-off-by: Daniel <7558512+DerGenaue@users.noreply.github.com>
Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Jérôme Benoit <jerome.benoit@sap.com>
2 weeks agofix(ocpp): reject charging profiles by EVSE existence and isolate non-persistent...
Jérôme Benoit [Fri, 17 Jul 2026 19:58:43 +0000 (21:58 +0200)] 
fix(ocpp): reject charging profiles by EVSE existence and isolate non-persistent station configuration (#2022, #2024) (#2025)

* fix(ocpp): reject charging profiles by EVSE existence, not EVSE count (#2022)

OCPP 2.0 validateChargingProfile() guarded evseId with a count comparison
(evseId > getNumberOfEvses()). getNumberOfEvses() counts EVSEs (excluding
EVSE 0) and is not a maximum id, so a station with non-contiguous EVSE ids
{0,1,3} false-rejected a valid profile for EVSE 3 (3 > 2). Use the existing
hasEvse() existence check instead, mirroring the OCPP 1.6 hasConnector twin,
and align the message to "EVSE does not exist".

RequestStartTransaction carries the TxProfile (F01.FR.08-10 / F02.FR.16-18);
existence-based rejection mirrors K01.FR.28 (conformance test TC_K_14_CS).

Test: a charging profile for the existing, non-contiguous EVSE 3 is accepted
(a count-based guard would reject it, 3 > 2). Mutation-verified: reverting the
guard makes the test fail.

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* fix(charging-station): clone template OCPP configuration to isolate non-persistent stations (#2024)

getOcppConfigurationFromTemplate() returned the SharedLRUCache-cached
template's Configuration by reference. For non-persistent stations
(ocppPersistentConfiguration=false) this aliased the shared configurationKey
array, so an in-place setConfigurationKeyValue() mutation polluted the cached
template. Clone the Configuration, mirroring the existing connector/EVSE clone
pattern (clone(undefined) === undefined).

Test: a non-persistent station's Configuration is an independent copy of the
cached template, and mutating a key on it leaves that cached template unchanged.
Mutation-verified: reverting the clone makes the test fail. Retires the
now-redundant SharedLRUCache-clearing afterEach workaround in
ChargingStation-ResetIdentity.test.ts (file still passes).

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
---------

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
2 weeks agofix(charging-station): retain creation options so a reset keeps station identity...
Daniel [Fri, 17 Jul 2026 17:10:37 +0000 (19:10 +0200)] 
fix(charging-station): retain creation options so a reset keeps station identity (#2019)

* fix(charging-station): retain creation options so a reset keeps station identity

On an OCPP Reset (and on a template-file reload) the station was
re-initialized via initialize() with no arguments, dropping the
creation-time options. The restarted station reverted to the template
defaults, losing its fixed identity (reappearing as CS-BASIC-000N with a
new hashId) and its configured supervision URL; the original station
never came back.

Retain the creation options on the instance and re-pass them in reset()
and in the template-watcher reload. setSupervisionUrl() also updates the
retained snapshot so a runtime supervision-URL change survives a reboot
(but not a full simulator restart, which reconstructs the worker from the
original options). The snapshot is in-memory only: it is never persisted
and never written to the template.

Closes #2017.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Daniel <7558512+DerGenaue@users.noreply.github.com>
* fix(charging-station): clone the retained creation options

setSupervisionUrl updates the retained options in place so a later reset
re-applies the current supervision URL. Clone them at construction so that
in-place update never mutates the shared worker data passed from the worker
thread.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Daniel <7558512+DerGenaue@users.noreply.github.com>
* test(charging-station): cover identity retention across a reset

A non-persistent station keeps its configured identity across a reset only
when the creation options are re-applied; a persistent one restores it from
its saved configuration without them. This locks the behavior and scopes the
options fix to the non-persistent case.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Daniel <7558512+DerGenaue@users.noreply.github.com>
* refactor(charging-station): re-apply retained options on reset only when non-persistent

Address review feedback on the identity-retention fix:
- Rename the retained-options field to creationOptions (clearer; no longer
  shadows the options parameters).
- Re-apply the creation options on reset/template-reload only for a
  non-persistent station, via the reinitializeOptions getter. A persistent
  station restores from its saved config, which stays the source of truth, so
  re-applying them would needlessly override it (and collapse an OCPP-config
  station's supervision URL). The unconditional setSupervisionUrl mirror is kept,
  with a sharpened comment explaining why it must run for both branches.
- Cover the reset -> initialize wiring (both persistence modes) and the
  setSupervisionUrl-across-reset retention with unit tests.

No change to the fixed behavior: a reset still keeps the configured identity.

* refactor(charging-station): factor setSupervisionUrl credential updates into a helper

Deduplicate the supervisionUser/supervisionPassword null-checks that were
applied twice (to stationInfo and to the retained creation options) into a
single applyCredentials() closure. No behavior change.

* test(charging-station): cover OCPP-config supervision URL retention across reset

- Add a regression test for the non-persistent supervisionUrlOcppConfiguration
  path: after setSupervisionUrl(), a real reset() must still dial the new URL
  (the branch the setSupervisionUrl mirror comment reasons about, previously
  untested).
- Prefix all test titles with "should" per tests/TEST_STYLE_GUIDE.md.
- Clear the real SharedLRUCache singleton in afterEach so a cached template
  cannot bleed between tests.
- Support optional template field overrides in the makeTemplate helper.

* refactor(charging-station): rename reinitialization getter and fix its mirror comment

- Rename the reinitializeOptions getter to reinitializationOptions (noun form,
  consistent with the wsConnectionUrl/hasEvses getters).
- Correct the setSupervisionUrl mirror comment: the retained-options mirror is
  load-bearing on a cache-cold reinitialization (template reload), not on a plain
  warm-cache reset() (whose in-place OCPP-key cache mutation already carries the
  URL). No behavior change.

* test(charging-station): add a genuine OCPP-config supervision-URL reload guard

The previous OCPP-config test drove a warm-cache reset() and passed even with the
retained-options mirror deleted (a false guard): the cached template still carried
the mutated OCPP key. Add a cache-cold reload test (clear SharedLRUCache, then
reinitialize) that re-seeds the key from configuredSupervisionUrl and therefore
fails if the mirror is removed. Relabel the warm-cache reset test and correct its
comment to reflect that it exercises the cache-mutation path, not the mirror.

* docs(charging-station): tighten the setSupervisionUrl mirror comment

Condense the retained-options mirror comment (~11 -> 8 lines) while preserving
every load-bearing detail: it runs for both branches, a cache-cold reload
re-seeds the OCPP key from configuredSupervisionUrl, a warm reset() survives via
the cached template mutation, and the mirror is in-memory only.

---------

Signed-off-by: Daniel <7558512+DerGenaue@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Jérôme Benoit <jerome.benoit@sap.com>
2 weeks agofix(charging-station): re-dial after a server-initiated connection close (#2021)
Daniel [Fri, 17 Jul 2026 15:22:18 +0000 (17:22 +0200)] 
fix(charging-station): re-dial after a server-initiated connection close (#2021)

* fix(charging-station): re-dial after a server-initiated connection close

When the CSMS or a proxy closed a station's WebSocket with a normal
(clean) close, the station stayed "started but not connected" instead of
reconnecting. onClose only re-dialed on abnormal close codes and treated
clean closes as terminal, but a clean close cannot be told apart by code
from the station's own closeWSConnection() (the UI disconnect action,
which must stay down). Real charge-point hardware re-dials after losing
the connection regardless of the close code.

Mark only explicitly-requested closes (the UI disconnect) as terminal via
a byRequest flag on closeWSConnection(), and reconnect on any close the
station did not request while it is still started -- clean or abnormal.
This also fixes certificate rotation, which closes the socket to force a
re-dial with the new certificate and previously never reconnected.

Closes #2016.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Daniel <7558512+DerGenaue@users.noreply.github.com>
* refactor(charging-station): pass closeWSConnection intent via an options object

Match the paired openWSConnection signature and make the call site
self-documenting: closeWSConnection({ byRequest: true }) rather than a bare
positional boolean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Daniel <7558512+DerGenaue@users.noreply.github.com>
* test(charging-station): cover the onClose reconnect decision

Drive onClose directly with a spied reconnect: the station re-dials after a
server-initiated normal close while started, and stays disconnected after an
operator-requested close. Removes a stale test that only asserted the socket
reached CLOSED, which held regardless of the reconnect decision.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Daniel <7558512+DerGenaue@users.noreply.github.com>
* docs(charging-station): harmonize reconnect terminology and tighten close comments

- Use the codebase's "reconnect" term consistently (drop "re-dial"), and
  "server-initiated"/"requested" to match the PR title and the byRequest option.
- Tighten the closeWSConnection JSDoc and onClose comments; align the JSDoc
  @param style (no trailing period) with the sibling openWSConnection.
- Prefix the reconnect test titles with "should" per tests/TEST_STYLE_GUIDE.md.

No behavior change.

---------

Signed-off-by: Daniel <7558512+DerGenaue@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Jérôme Benoit <jerome.benoit@sap.com>
2 weeks agorefactor(ocpp): use iterateConnectors() in OCPP 1.6 incoming request service (#2015)
Jérôme Benoit [Fri, 17 Jul 2026 15:14:04 +0000 (17:14 +0200)] 
refactor(ocpp): use iterateConnectors() in OCPP 1.6 incoming request service (#2015)

Closes #2014

2 weeks agochore(deps): lock file maintenance (#2005)
renovate[bot] [Fri, 17 Jul 2026 11:32:24 +0000 (13:32 +0200)] 
chore(deps): lock file maintenance (#2005)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2 weeks agochore(deps): update actions/setup-node action to v7 (#2008)
renovate[bot] [Thu, 16 Jul 2026 23:47:04 +0000 (01:47 +0200)] 
chore(deps): update actions/setup-node action to v7 (#2008)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2 weeks agofix(deps): update all non-major dependencies (#2007)
renovate[bot] [Thu, 16 Jul 2026 11:36:48 +0000 (13:36 +0200)] 
fix(deps): update all non-major dependencies (#2007)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2 weeks agofix(ocpp): support TriggerMessage(TransactionEvent) per F06.FR.07 (#2013)
Jérôme Benoit [Thu, 16 Jul 2026 11:23:56 +0000 (13:23 +0200)] 
fix(ocpp): support TriggerMessage(TransactionEvent) per F06.FR.07 (#2013)

TriggerMessage(requestedMessage=TransactionEvent) returned NotImplemented
although TransactionEvent is a fully implemented outgoing message, violating
OCPP 2.0.1 F06.FR.07/08.

Wire the trigger to the existing TransactionEvent send path across the two
touchpoints:
- handleRequestTriggerMessage: add a TransactionEvent case that validates the
  evse, returns Accepted when an ongoing transaction exists in scope, else
  Rejected (F06.FR.05); it no longer falls through to NotImplemented.
- TRIGGER_MESSAGE dispatch listener: add a TransactionEvent case that sends a
  TransactionEventRequest(eventType=Updated, triggerReason=Trigger) with the
  TxUpdatedMeasurands meterValue for each active transaction in scope; when the
  evse field is absent it fans out to all EVSEs (F06.FR.11).

Reuses sendTransactionEvent and the TxUpdatedMeasurands meter-value builder;
chargingState is populated by buildTransactionEvent for Updated events. The 6
existing trigger cases are unchanged.

Closes #2012

2 weeks agostyle: codebase consistency sweep — JSDoc @returns backticks + != null idiom (#2009)
Jérôme Benoit [Wed, 15 Jul 2026 16:56:37 +0000 (18:56 +0200)] 
style: codebase consistency sweep — JSDoc @returns backticks + != null idiom (#2009)

Behavior-preserving codebase style-consistency sweep. Closes #1966.

- Backtick 49 JSDoc @returns boolean literals across 17 files (lowercase True/False).
- Align !== undefined -> != null on number|undefined guards in OCPP20IncomingRequestService.ts (13 tokens); conditional-spread inclusion guards preserved as !== undefined.
- Extend != null idiom to OCPP20VariableManager min/max ternaries + presence guards and the negated EVSE guard.
- Fix OCPP20AuthAdapter.validateConfiguration @returns (synchronous boolean; was falsely 'Promise resolving to').

Gap C (String(previousRequestId) -> .toString()) dropped: premise false — operand is number|undefined, .toString() would fail typecheck (TS18048); String() is the only behavior-preserving form.

All gates green (format/typecheck/lint/build/test, fail=0). Reviewed across multiple multi-agent rounds with cross-validation.

2 weeks agochore(deps): update all non-major dependencies (#2006)
renovate[bot] [Tue, 14 Jul 2026 11:18:57 +0000 (13:18 +0200)] 
chore(deps): update all non-major dependencies (#2006)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2 weeks agochore(deps): update all non-major dependencies (#2004)
renovate[bot] [Mon, 13 Jul 2026 14:18:41 +0000 (16:18 +0200)] 
chore(deps): update all non-major dependencies (#2004)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2 weeks agorefactor(ocpp): remove as-unknown-as double-casts from request/response dispatch...
Jérôme Benoit [Sun, 12 Jul 2026 19:54:24 +0000 (21:54 +0200)] 
refactor(ocpp): remove as-unknown-as double-casts from request/response dispatch (#2003)

Give each builder its precise input contract instead of laundering casts
through phantom generics or JsonType:

- buildRequestPayload drops its unsound <Request> generic and returns honest
  JsonType; each dispatch branch narrows with a single cast at a genuine
  JsonType boundary.
- Builder inputs (StatusNotificationOptions, OCPP20TransactionEventOptions,
  SignCertificateOptions) are JsonObject subtypes; StatusNotification build
  validates connectorStatus via the shared isOCPP20ConnectorStatus guard.
- Handler bridges (toRequestHandler/toResponseHandler) use a single cast that
  preserves the handler's async identity, fixing an unawaited-handler
  regression from a plain wrapper defeating isAsyncFunction.
- CAT-C string-to-enum narrowing becomes a type guard / typed Record.

Closes #1968

2 weeks agorefactor(utils): swap getRandomFloat(Rounded) params to (min, max) for consistency...
Jérôme Benoit [Sun, 12 Jul 2026 12:42:29 +0000 (14:42 +0200)] 
refactor(utils): swap getRandomFloat(Rounded) params to (min, max) for consistency (#1998)

Swap getRandomFloat and getRandomFloatRounded params from (max, min[, scale]) to (min, max[, scale]) for consistency with the sibling bounds helpers randomInt (node:crypto) and isValidRandomIntBounds; migrate all 11 production + 5 test call sites; harmonize JSDoc across the random-float family.

Bundles a correctness fix: getRandomFloat now rejects a non-finite interval width (max - min), closing a latent overflow where finite endpoints such as (-MAX_VALUE, MAX_VALUE) returned Infinity/NaN, silently violating the documented [min, max] contract.

Internal API refactor: package.json exports only ./dist/start.js, so getRandomFloat is app-internal and this is not a semver-major public break.

Closes #1967

3 weeks agotest(ocpp): cover OCPP 2.0.1 log/firmware lifecycle supersession and cleanup paths...
Jérôme Benoit [Sat, 11 Jul 2026 23:15:05 +0000 (01:15 +0200)] 
test(ocpp): cover OCPP 2.0.1 log/firmware lifecycle supersession and cleanup paths (#1997)

3 weeks agofix(deps): update all non-major dependencies (#1996)
renovate[bot] [Sat, 11 Jul 2026 11:30:22 +0000 (13:30 +0200)] 
fix(deps): update all non-major dependencies (#1996)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
3 weeks agochore(deps): lock file maintenance (#1958)
renovate[bot] [Fri, 10 Jul 2026 20:42:50 +0000 (22:42 +0200)] 
chore(deps): lock file maintenance (#1958)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
3 weeks agofix(ocpp): guard OCPP 1.6 UpdateFirmware deferred-timer schedule against sealed state...
Jérôme Benoit [Fri, 10 Jul 2026 13:09:12 +0000 (15:09 +0200)] 
fix(ocpp): guard OCPP 1.6 UpdateFirmware deferred-timer schedule against sealed state writes (#1994)

The .on(UPDATE_FIRMWARE, ...) listener's deferred branch obtains state via
getOrCreateStationState() and then writes stationState.deferredFirmwareUpdateTimer
= setTimeout(...). After PR #1992 sealed the base-plumbing behavior,
getOrCreateStationState() returns the stopped state instead of lazy-initializing
a fresh entry once stop() has marked stationsState.get(cs)?.stopped === true. A
late UPDATE_FIRMWARE dispatch after ChargingStation.stop() runs
ocppIncomingRequestService.stop(this) at ChargingStation.ts:1273 (before
this.started = false at :1280) therefore writes a fresh Timeout onto the sealed
state.

The callback body is safe (unref'd, checkChargingStationState gate), but the
write is inconsistent with the OCPP 2.0.1 GUARD sites shipped in PR #1992
(getCertSigningRetryManager, sendSecurityEventNotification,
simulateFirmwareUpdateLifecycle, simulateLogUploadLifecycle) and leaves a Timeout
closure-referenced entry pending in Node's queue until GC reclaims the
ChargingStation.

Insert a stopped GUARD immediately after getOrCreateStationState, before
cancelDeferredFirmwareUpdate — mirroring the exact syntax of the OCPP 2.0.1
GUARD sites (bare 'return' after 'if (stationState.stopped === true) {'). The
GUARD short-circuits the schedule branch before any write reaches the sealed
state.

Add a regression test covering the post-stop() deferred-schedule path in
tests/charging-station/ocpp/1.6/OCPP16IncomingRequestService-Firmware.test.ts.

Closes #1993

Touchpoint:
- src/charging-station/ocpp/1.6/OCPP16IncomingRequestService.ts:705 — stopped GUARD

References:
- PR #1992 — source of the 'stopped' sentinel + OCPP 2.0.1 GUARD sites
- PR #1984 — source of 'deferredFirmwareUpdateTimer'

This is purely additive; no observable OCPP behavior change (the pre-existing
fire-time checkChargingStationState gate inside the setTimeout callback remains
as defense-in-depth).

3 weeks agochore(deps): update all non-major dependencies (#1995)
renovate[bot] [Fri, 10 Jul 2026 12:37:03 +0000 (14:37 +0200)] 
chore(deps): update all non-major dependencies (#1995)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
3 weeks agofix(ocpp): guard post-stop() handler dispatch against WeakMap resurrection (#1992)
Jérôme Benoit [Thu, 9 Jul 2026 22:45:56 +0000 (00:45 +0200)] 
fix(ocpp): guard post-stop() handler dispatch against WeakMap resurrection (#1992)

Closes #1970

OCPP 2.0.1 handlers dispatched from queued microtasks, unref'd retry
timers, or EventEmitter-dispatched `.on(...)` listeners could run
AFTER `stop()` and resurrect the `stationsState` WeakMap entry via
`getOrCreateStationState` lazy-init. `sendQueuedSecurityEvents` also
scheduled a retry `setTimeout(...)` whose handle was never stored, so
`stop()` had no way to cancel a pending retry, and the recursive
retry callback re-entered `sendQueuedSecurityEvents` — resurrecting
state.

Three coordinated parts.

Part 1 — base plumbing (`OCPPIncomingRequestService.ts`):

- Generic bound widened to
  `TStationState extends { stopped?: boolean } = { stopped?: boolean }`.
- `stop()` template: after `resetStationState`, marks
  `stationState.stopped = true` instead of `stationsState.delete(cs)`.
  Deletion would re-enable resurrection via `getOrCreateStationState`
  lazy-init on any late dispatch. Keeping the sealed entry lets the
  getter return the sealed state and raw `.get()` null-guarded callers
  observe the marker and drop. The WeakMap entry is naturally
  collected when the ChargingStation reference is dropped.
- `getOrCreateStationState` returns the sealed stopped state when
  `state?.stopped === true`; no fresh entry is lazy-init'd post-stop.
- Both concrete state interfaces (`OCPP16StationState`,
  `OCPP20StationState`) get an alphabetized `stopped?: boolean` field.

Part 2 — OCPP 2.0.1 call-site audit (11 sites):

Two sites converted to raw `stationsState.get(cs)` + `stopped === true`
null-guard, silent-drop:

- `sendNotifyReportRequest` — dispatched from
  `.on(GET_BASE_REPORT).catch(...)` microtask; state usually created
  by sibling `handleRequestGetBaseReport` but external `emit(...)`
  paths and post-stop dispatch are covered by the null-guard.
- `sendQueuedSecurityEvents` — dispatched from unref'd retry
  `setTimeout`; guard is the last line of defense against a
  fired-before-cancel race.

Four sites use `getOrCreateStationState` + explicit `stopped` check
(state may not exist yet on the pre-stop happy path):

- `sendSecurityEventNotification` — lifecycle-entry on the
  invalid-cert-on-first-request edge case
  (`handleRequestCertificateSigned` X.509-invalid branch,
  `handleRequestUpdateFirmware` invalid-PEM branch).
- `getCertSigningRetryManager` — public accessor; return type widened
  to `OCPP20CertSigningRetryManager | undefined`; two callers
  (`handleRequestCertificateSigned`, `OCPP20ResponseService`) updated
  with `?.` to tolerate the undefined return. Without this guard, a
  late `handleResponseSignCertificate` dispatch (a SignCertificate
  response can arrive between `ocppIncomingRequestService.stop(cs)`
  and `closeWSConnection()` in `ChargingStation.stop()`) would
  materialize a fresh manager on the sealed state and schedule a
  non-`.unref()`'d retry `setTimeout` holding the ChargingStation
  reference.
- `simulateFirmwareUpdateLifecycle` — dispatched from
  `.on(UPDATE_FIRMWARE).catch(...)` microtask; guards against driving
  the full async lifecycle on a sealed state (which would send OCPP
  requests through a closed WebSocket and clobber the abort-controller
  fields).
- `simulateLogUploadLifecycle` — symmetric,
  `.on(GET_LOG).catch(...)`.

Five sites kept unchanged (Part 1 guard covers them):
`getRestoredConnectorStatus`, `handleRequestGetBaseReport`,
`handleRequestUpdateFirmware`, `savePreInoperativeStatuses`,
`sendFirmwareStatusNotification`.

Part 3 — retry timer discipline in `sendQueuedSecurityEvents`:

- Store the retry `setTimeout` handle on
  `OCPP20StationState.securityEventRetryTimer`.
- Cancel any previously scheduled retry before scheduling a new one.
- Self-clear the handle before the recursive
  `sendQueuedSecurityEvents` call inside the callback.
- New `cancelSecurityEventRetryTimer` helper is called from
  `resetStationState` (after abort-controller aborts and cert-signing
  retry cancel, before the two `resetActive*State` helpers).
- `.unref()` preserved.

Files touched (7 modified + 1 new):

- `src/charging-station/ocpp/OCPPIncomingRequestService.ts`
- `src/charging-station/ocpp/1.6/OCPP16IncomingRequestService.ts`
- `src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.ts`
- `src/charging-station/ocpp/2.0/OCPP20ResponseService.ts`
- `tests/charging-station/ocpp/OCPPIncomingRequestService-StationState.test.ts`
- `tests/charging-station/ocpp/1.6/OCPP16IncomingRequestService-Firmware.test.ts`
- `tests/charging-station/ocpp/1.6/OCPP16IncomingRequestService-GetDiagnostics.test.ts`
- `tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-PostStopResurrection.test.ts`
  (new)

Design rationale — silent-drop over `StationStoppedError` throw:
matches the "post-stop silently drop" semantic and avoids catch-block
sprawl at every handler.

Design rationale — return `undefined` from `getCertSigningRetryManager`
over convert-with-explicit-null-manager: two callers already handle
optional chaining cleanly; return-type widening is the minimum change.

Verification: pnpm format / typecheck / lint pass; pnpm test 3029
pass, 0 fail; pnpm build exit 0. No `stationsState.delete` remains in
`src/`; `securityEventRetryTimer` lives in exactly three logical
places (interface field, retry-site store + self-clear, cancel helper
called from `resetStationState`).

References: #1983 (shared base plumbing), #1984 (OCPP 1.6
`deferredFirmwareUpdateTimer` timer discipline).

3 weeks agofix(ocpp): implement OCPP 1.6 GetDiagnostics supersession via AbortController (#1991)
Jérôme Benoit [Thu, 9 Jul 2026 21:57:29 +0000 (23:57 +0200)] 
fix(ocpp): implement OCPP 1.6 GetDiagnostics supersession via AbortController (#1991)

Closes #1971

The entry guard added by PR #1987 at handleRequestGetDiagnostics
prevented concurrent FTP uploads racing on the same
${chargingStationId}_logs.tar.gz archive by silently dropping the
second GetDiagnostics.req with an empty GetDiagnostics.conf. That
mitigated the concrete race but was not the OCPP 1.6 supersession
semantic: the new request needed to abort the in-flight upload and
start a fresh one, with a terminal DiagnosticsStatusNotification
emitted for the superseded upload.

Refactor the entry guard from skip-second into abort-then-install:

- Add `activeDiagnosticsAbortController?: AbortController` to
  `OCPP16StationState` (alphabetical, above the existing fields).
- On supersession, the entry guard calls
  `stationState.activeDiagnosticsAbortController?.abort()` and
  installs a fresh controller. `diagnosticsUploadInProgress` stays
  `true` across the handoff so the §4.4 / §7.24 trigger cross-check
  never observes a false-idle window.
- `basic-ftp` v6 does not natively consume `AbortSignal`; the
  documented interruption path is `Client.close()`, which invokes
  `FtpContext.close()` and rejects the pending `uploadFrom` task
  with `User closed client during task`. The handler wires
  `signal.addEventListener('abort', () => ftpClient?.close(), { once: true })`.
- The existing `catch` at L1390 already emits
  `DiagnosticsStatusNotification(UploadFailed)` for any thrown error,
  so a close-triggered rejection flows through unchanged — no second
  emission needed. OCPP 1.6 `DiagnosticsStatusNotification.req` (§6.17)
  does not carry a `requestId`; the terminal for the superseded upload
  is uncorrelated by design, only its temporal position tells the CSMS
  which upload it belonged to.
- The `finally` clause identity-guards its cleanup
  (`if (stationState.activeDiagnosticsAbortController === abortController)`)
  so a superseded handler's late unwind cannot clobber the new
  lifecycle's state. Cleaner than the microtask-yield primitive
  sketched in the initial design because it removes the ordering
  dependency between the two handlers' async continuations.
- `resetStationState` aborts the in-flight controller before the base
  template deletes the WeakMap entry, following the cancel-before-delete
  ordering documented on `OCPP20IncomingRequestService.resetStationState`.

Mirrors the OCPP 2.0.1 log-upload supersession pattern (commits
29a8330fac6ed21864f384fabe29b4c8) but does not import
`AcceptedCanceled` — that response semantic is a 2.0.1 concept absent
from OCPP 1.6 `GetDiagnostics.conf` (§6.26).

3 weeks agofix(ocpp): cross-check OCPP 1.6 lifecycle flags in trigger, re-entry, and simulation...
Jérôme Benoit [Thu, 9 Jul 2026 17:08:03 +0000 (19:08 +0200)] 
fix(ocpp): cross-check OCPP 1.6 lifecycle flags in trigger, re-entry, and simulation entry (#1987)

The OCPP 1.6 TriggerMessage handler cases for DiagnosticsStatusNotification and
FirmwareStatusNotification read stationInfo.diagnosticsStatus and
stationInfo.firmwareStatus directly. When an exception, abort, or lifecycle
drift left stationInfo.*Status at a stale non-terminal value, the trigger
faithfully reported that stale value to the CSMS instead of Idle.

The identical anti-pattern was present in handleRequestUpdateFirmware's re-entry
guard (which suppressed legitimate new UpdateFirmware.req when
stationInfo.firmwareStatus was stuck at Downloading / Downloaded / Installing)
and in the base-class event dispatcher's unconditional fan-out of
UPDATE_FIRMWARE emits (which spawned duplicate concurrent
updateFirmwareSimulation invocations emitting duplicate progress notifications
to the CSMS).

Track lifecycle progress with two per-station boolean flags on
OCPP16StationState (diagnosticsUploadInProgress, firmwareUpdateInProgress), set
on lifecycle entry and cleared in a finally block on every exit path (happy,
return, throw). The flags are consumed at four sites: the two TriggerMessage
cases, the handleRequestUpdateFirmware re-entry guard, and the
updateFirmwareSimulation entry guard.

OCPP 1.6 GetDiagnostics.req and UpdateFirmware.req core-profile payloads do not
carry a requestId (unlike OCPP 2.0.1 GetLog and UpdateFirmware), so lifecycle
progress is tracked with boolean flags rather than requestId-presence
sentinels.

Satisfies OCPP 1.6 SHALL clauses in section 4.4 (Diagnostics) and section 4.5
(Firmware), and the Idle enumeration definitions in section 7.24 and
section 7.25.

Closes #1973

3 weeks agochore(deps): update dependency eslint-plugin-jsdoc to ^63.0.12 (#1990)
renovate[bot] [Thu, 9 Jul 2026 07:03:51 +0000 (09:03 +0200)] 
chore(deps): update dependency eslint-plugin-jsdoc to ^63.0.12 (#1990)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
3 weeks agofix(ocpp): cancel deferred OCPP 1.6 firmware setTimeout on stop() (#1984)
Jérôme Benoit [Wed, 8 Jul 2026 22:19:05 +0000 (00:19 +0200)] 
fix(ocpp): cancel deferred OCPP 1.6 firmware setTimeout on stop() (#1984)

The OCPP 1.6 UPDATE_FIRMWARE event listener schedules
updateFirmwareSimulation via setTimeout(...).unref() when retrieveDate
is in the future. .unref() prevents the timer from blocking process
exit, but the callback still fires after stop(): if the station
restarts, the deferred simulation runs against the new connection and
can emit FirmwareStatusNotification messages the CSMS did not request.

Store the timer handle on OCPP16StationState.deferredFirmwareUpdateTimer
(per-station state introduced by #1963 / PR #1983) and release it via
a shared cancelDeferredFirmwareUpdate helper called from both the
schedule site (to supersede a prior pending schedule) and
resetStationState (invoked by the inherited stop() template before the
base deletes the WeakMap entry). The callback clears the handle before
awaiting updateFirmwareSimulation so resetStationState never targets a
fired timer.

Mirrors the OCPP 2.0.1 pattern
(OCPP20IncomingRequestService.resetStationState cancels
certSigningRetryManager.cancelRetryTimer, commit c0b25553).

Closes #1972

3 weeks agorefactor(ocpp): extract per-station state plumbing into shared OCPPIncomingRequestSer...
Jérôme Benoit [Wed, 8 Jul 2026 19:45:50 +0000 (21:45 +0200)] 
refactor(ocpp): extract per-station state plumbing into shared OCPPIncomingRequestService base (#1983)

Closes #1963. Companion issues #1971, #1972, #1973 will consume this base.

Pure structural refactor + OCPP 1.6 concretization. Zero observable
behavior change on OCPP 2.0.1: every field currently cleared in the
former OCPP20IncomingRequestService.stop() body is still cleared, in
the same order, at the same lifecycle point.

Touchpoints:

A. src/charging-station/ocpp/OCPPIncomingRequestService.ts
   Base becomes generic <TStationState extends object = object>. Adds
   shared 'stationsState' WeakMap (L86), 'getOrCreateStationState'
   lazy-init, concrete 'stop()' template (bound in the constructor at
   L94 to prevent detach-and-call regressions), and abstract hooks
   'createStationState' / 'resetStationState'. Documents the
   exception-safety contract on the template: a throw in
   'resetStationState' skips WeakMap eviction and any subclass
   extension after 'super.stop()' — matches pre-refactor semantics.
   The '= object' default on the generic parameter is load-bearing
   (removing it would break the static registry Map and the
   getInstance constraint with TS2314).

B. src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.ts
   Extends OCPPIncomingRequestService<OCPP20StationState>. Removes
   local 'stationsState' field and 'getOrCreateStationState'. Adds
   'createStationState' factory and 'resetStationState' override with
   the 5-statement ordering invariant of the pre-refactor stop() body.
   The abort-before-clear ordering matters because clearing first
   makes the subsequent '?.abort()' short-circuit on the nulled field,
   leaving the in-flight operation un-signaled. 'stop()' override
   calls super first, then keeps the unconditional
   OCPP20VariableManager cleanup. Class-level JSDoc documents OCPP 2.1
   subclass path (extends OCPP20IncomingRequestService inherits state,
   reset, and stop() unchanged; widening the generic parameter for new
   fields requires a preparatory refactor with a factory cast per
   TS2352, or subclass override of createStationState).

C. src/charging-station/ocpp/1.6/OCPP16IncomingRequestService.ts
   Adds empty 'OCPP16StationState' interface. Extends
   OCPPIncomingRequestService<OCPP16StationState>. Adds no-op
   'createStationState' + 'resetStationState' overrides. Removes the
   '/* no-op for OCPP 1.6 */' stop() override (now inherited from the
   base template). The 'resetStationState' JSDoc prescribes the
   abort-before-clear ordering companion issues MUST follow.

D. eslint.config.js
   Adds 'no-restricted-syntax' rule enforcing the INVARIANT: forbids
   direct '.set/.delete/.clear' calls on 'stationsState' from outside
   the base class file, forbids aliasing 'stationsState' to a local
   binding, and forbids destructuring 'stationsState'. Covers the AST
   escape hatches identified during hostile-adversarial review.
   Enforced by 'pnpm lint' in CI on every companion PR.

E. src/charging-station/ocpp/OCPPServiceUtils.ts
   Adds bidirectional cross-reference in the comment on
   'warnedInvalidMeasurands' distinguishing the module-scope warn-once
   diagnostic cache from the class-scope lifecycle-state WeakMap on
   OCPPIncomingRequestService.stationsState.

Companion issues consume this base infrastructure in order:
  - #1971 GetDiagnostics supersession -> activeDiagnosticsAbortController + Id
  - #1972 firmware setTimeout cancel  -> deferred firmware timer handle
  - #1973 trigger cross-check         -> activeFirmwareUpdateRequestId

#1971 and #1973 share 'activeDiagnosticsRequestId': whichever lands
first adds the (optional) field to OCPP16StationState; the second
lands as-is.

Line-count delta (plumbing moved, not duplicated):
  Base   :  182 -> 290 (+108) plumbing + docs (~+80 JSDoc / +28 code)
  OCPP20 : 4579 -> 4627  (+48) 22 lines of plumbing removed; docs +55,
                                 net code -7 (concrete overrides only)
  OCPP16 : 1958 -> 1985  (+27) interface + 2 concrete implementations
                                 + companion guidance
  eslint : 172 -> 198   (+26) 3-selector no-restricted-syntax rule
  utils  : 2000 -> 2004  (+4)  bidirectional cross-ref comment
  test   : new file (+150) 7 plumbing tests

Origin of the OCPP 2.0.1 pattern being harmonized:
  - 47cdf2bbe refactor(ocpp20): isolate per-station state with
    WeakMap instead of singleton properties (introduces the WeakMap
    pattern with initial names 'OCPP20PerStationState' / 'stationStates'
    / 'getStationState')
  - f1e33ea42 refactor(ocpp20): harmonize per-station state naming
    (renames to 'OCPP20StationState' / 'stationsState')
  - 17396c1c4 refactor(ocpp20): rename getStationState to
    getOrCreateStationState (lazy-getter naming split)
  - c0b25553 fix(ocpp20): cancel cert-signing retry timer in stop()
  - be29b4c8 fix(ocpp20): add AbortController to log-upload lifecycle
    + stop() abort
  - d2e9b8b0 refactor(ocpp20): extract resetActiveFirmwareUpdateState
    helper
  - ce875dae refactor(ocpp20): harmonize firmware-state cleanup + log
    superseded

Tests: 7 new plumbing tests in
tests/charging-station/ocpp/OCPPIncomingRequestService-StationState.test.ts
covering lazy-init idempotency (with createStationState spy verifying
call count = 1 after two getOrCreate calls), WeakMap eviction on
stop(), resetStationState invocation count, no-op guard when no state
exists, two-station isolation, the OCPP 1.6 default resetStationState
no-op (with reference identity check), and the exception-safety
contract lock (throw in resetStationState skips WeakMap.delete, and
subsequent stop() re-invokes on same state before evicting). Zero
edits to existing OCPP 2.0.1 tests.

The OCPP 2.0.1 5-statement ordering invariant is enforced by four
independent mechanisms: (1) JSDoc rationale on
OCPP20IncomingRequestService.resetStationState documenting the
'?.abort()' short-circuit consequence of reordering, (2) the
'no-restricted-syntax' ESLint rule preventing direct 'stationsState'
mutations from subclass code (with selector coverage for aliasing and
destructuring bypasses; enforced in CI), (3) constructor
'stop.bind(this)' defense-in-depth against detach-and-call
regressions, and (4) byte-identical preservation of the pre-refactor
stop() body.

3 weeks agofix(ocpp): drive FirmwareStatusNotification trigger from last-sent notification ...
Jérôme Benoit [Wed, 8 Jul 2026 16:10:40 +0000 (18:10 +0200)] 
fix(ocpp): drive FirmwareStatusNotification trigger from last-sent notification (L01.FR.26) (#1981)

TriggerMessage(FirmwareStatusNotification) was gated on
`activeFirmwareUpdateRequestId != null && hasFirmwareUpdateInProgress()`,
falling back to Idle otherwise. Since `hasFirmwareUpdateInProgress` returns
false for every terminal status (`DownloadFailed`, `InvalidSignature`,
`InstallationFailed`, `InstallVerificationFailed`, `Installed`), the
station returned Idle after every non-Installed terminal, violating OCPP
2.0.1 L01.FR.26 (SHALL, mirrored by L02.FR.17).

Persist the last-sent `(requestId, status)` on `OCPP20StationState` and
drive the trigger from it. `stationInfo.firmwareStatus` is retained
because `hasFirmwareUpdateInProgress` and its 3 consumers (reset
rejection, isChargingStationIdle, isEvseIdle) depend on its
"in-progress predicate" semantics — deliberate dual-source, not
conflation.

Closes #1979

3 weeks agochore(gitignore): ignore user-local ev-profiles JSON files (#1982)
Jérôme Benoit [Wed, 8 Jul 2026 14:44:03 +0000 (16:44 +0200)] 
chore(gitignore): ignore user-local ev-profiles JSON files (#1982)

Add `src/assets/ev-profiles*.json` with negation for
`ev-profiles-template.json`, alongside the existing `config` and
`idtags` patterns. The three pairs are semantically identical:
user-mutable local instance + committed template.

The `ev-profiles.json` user file was previously untracked and
surfaced as noise in `git status`; the template
(`ev-profiles-template.json`) is documented in the README as the
canonical starting point for the `evProfilesFile` template field
consumed by the coherent MeterValues generator.

Verified with `git check-ignore -v`:
- `src/assets/ev-profiles.json`         → matched by .gitignore:7
- `src/assets/ev-profiles-template.json` → NOT ignored (negation)

3 weeks agochore(deps): update all non-major dependencies (#1978)
renovate[bot] [Wed, 8 Jul 2026 13:03:10 +0000 (15:03 +0200)] 
chore(deps): update all non-major dependencies (#1978)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
3 weeks agofix(ocpp): reset stationInfo.firmwareStatus on abort/exception in simulateFirmwareUpd...
Jérôme Benoit [Wed, 8 Jul 2026 12:46:10 +0000 (14:46 +0200)] 
fix(ocpp): reset stationInfo.firmwareStatus on abort/exception in simulateFirmwareUpdateLifecycle (#1976)

simulateFirmwareUpdateLifecycle wrote stationInfo.firmwareStatus at every
status transition via sendFirmwareStatusNotification, but its finally block
only cleared the per-station WeakMap state (activeFirmwareUpdateAbortController,
activeFirmwareUpdateRequestId). On exception or abort (supersession, stop()),
stationInfo.firmwareStatus stayed at the last-emitted non-terminal value
(e.g. 'Downloading'), leaving a latent data-model split any future
consumer that does not cross-check activeFirmwareUpdateRequestId would
observe.

Track the current lifecycle stage inside the lifecycle scope and, in the
existing finally, reset stationInfo.firmwareStatus in-place when the
lifecycle did not reach Installed. Per OCPP 2.0.1 FirmwareStatusEnumType,
Idle SHALL only be used in TriggerMessage-triggered notifications, so as
a persistent local state Idle is only valid before install starts:
  - clean download-phase abort              -> Idle
  - clean install-phase abort               -> InstallationFailed
  - exception during download stage         -> DownloadFailed
  - exception during install stage          -> InstallationFailed

Per OCPP 2.0.1 L01.FR.24 Note the terminal *Failed FirmwareStatusNotification
on supersession is optional; L02.FR.15 Note omits any such clause. Emitting
no FirmwareStatusNotification from finally is safe under both profiles.
The field is reset in-place; no FirmwareStatusNotification is emitted
from finally, so the simulator does not race the caller driving the
supersession.

Stage transitions are placed only after successful advancement (after the
Installing / Installed notification awaits resolve), never on error/abort
branches, so the terminal-value selection in finally is unambiguous. The
finally guards mirror clearActiveFirmwareUpdate's requestId-supersession
pattern to avoid clobbering a superseder's live status, and preserve any
explicit terminal already emitted from inside try (DownloadFailed /
InvalidSignature / InstallationFailed).

Stage->terminal mapping uses `Record<Exclude<FirmwareStage, 'installed'>,
OCPP20FirmwareStatusEnumType>` with `as const satisfies` for
compile-time exhaustiveness over the failure-eligible stages. Naming
convention (SCREAMING_SNAKE_CASE) matches CoherentMeterValueBuilder's
PHASE_FAMILY/PHASE_RANK pattern for private module-scope Record lookup
tables.

Add OCPP20VariableManager.getInstance().resetRuntimeOverrides() to the
shared standardCleanup test helper so per-station variable overrides do
not leak between tests (all mock stations share a hardcoded hashId).

Add 8 tests covering: supersession mid-download -> Idle, supersession
mid-install -> InstallationFailed, throw during download -> DownloadFailed,
throw during install -> InstallationFailed, happy path preserved -> Installed,
supersession-with-emit race (T2 launched via constructor listener) ->
Downloading preserved, InvalidSignature preserved, retries-exhausted
DownloadFailed preserved.

Closes #1969

3 weeks agofix(atg): invalidate configurationValidationResult on ChargingStationEvents.updated...
Jérôme Benoit [Wed, 8 Jul 2026 12:45:52 +0000 (14:45 +0200)] 
fix(atg): invalidate configurationValidationResult on ChargingStationEvents.updated (#1977)

Subscribe to ChargingStationEvents.updated in AutomaticTransactionGenerator's
constructor so in-flight configuration mutations that reach any charging
station mutation emit cannot leave a stale memoized validation decision
behind. stop() still clears the cache as a redundant safety net.

deleteInstance unsubscribes the handler before removing the entry so the
retired instance is not retained via the charging station's listener array
across a getInstance/deleteInstance churn cycle.

Fixes #1965.

3 weeks agochore(deps): update all non-major dependencies (#1975)
renovate[bot] [Tue, 7 Jul 2026 13:14:03 +0000 (15:14 +0200)] 
chore(deps): update all non-major dependencies (#1975)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
3 weeks agorefactor: audit backlog cleanup — JSDoc gaps, physics warning, ATG robustness, commen...
Jérôme Benoit [Tue, 7 Jul 2026 00:12:51 +0000 (02:12 +0200)] 
refactor: audit backlog cleanup — JSDoc gaps, physics warning, ATG robustness, comment sweep (#1962)

* docs: fill JSDoc gaps on public/protected abstract methods

Nine public/protected abstract methods across five base classes lacked
JSDoc, forcing new implementers to read existing subclasses to
reconstruct the contract. Add minimal-accurate JSDoc (one-line
description, @param, @returns) matching the style used by neighboring
already-documented methods in the same files.

Covered:
- Storage.close / open / storePerformanceStatistics
  (contract for MongoDB / MariaDB / MySQL / SQLite / JSON storage
   backends)
- OCPPIncomingRequestService.stop
- OCPPIncomingRequestService.isIncomingRequestCommandSupported
- OCPPRequestService.requestHandler
  (contract for OCPP 1.6 / 2.0 request services)
- OCPPResponseService.isRequestCommandSupported
- AbstractUIServer.sendRequest / sendResponse
  (contract for HTTP / WebSocket / stdio UI transports)

No behavior change. Gates pass (format / typecheck / lint).

* fix(physics): warn on voltageOut=400 V likely intended as line-to-line

voltageOut is line-to-neutral (V_LN) throughout the simulator. V_LL is
derived as sqrt(3) * V_LN in a balanced 3-phase Y system (see
OCPPServiceUtils.buildVoltageMeasurandValue and
CoherentMeterValueBuilder). The Voltage enum offers four closed values:
110, 230, 400, 800.

Users familiar with EU 3-phase infrastructure often configure
voltageOut=400 V thinking of the standard 400 V line-to-line nominal,
unaware that the simulator interprets it as line-to-neutral. Downstream
this yields an unrealistic simulated L-L ~= 693 V that only surfaces
when a meter value is inspected.

Emit a targeted logger.warn once per station init when
(currentOutType === AC && numberOfPhases === 3 && voltageOut === 400)
so the ambiguity surfaces at station start and the message tells the
user how to reach 400 V L-L (set voltageOut=230, the closest enum L-N
value).

No behavior change beyond the log line. Voltage.VOLTAGE_400 remains a
valid L-N configuration for the fraction of deployments that truly
want a 400 V L-N (rare but not unphysical), so the warning is not an
error. Gates pass (format / typecheck / lint).

* fix(atg): make in-flight transaction wait AbortSignal-aware

The AutomaticTransactionGenerator run loop at internalStartConnector
uses two long sleeps drawn from randomInt-clamped uniform distributions:

  - Line 319: await sleep(wait), up to maxDelayBetweenTwoTransactions
  - Line 345: await sleep(waitTrxEnd), up to maxDuration (potentially
    hours for a realistic charging session)

Neither observed the connectorsStatus.get(connectorId)?.start flag while
sleeping. stopConnector() flipped start=false immediately, but the run
loop only saw it AFTER the current sleep expired — so a maxDuration=3600
config could keep the loop alive for an entire hour after a stop request.

Introduce a small module-scope interruptibleSleep(ms, signal) primitive
that races setTimeout against an AbortSignal, cleaning up whichever
listener does not win. Add a per-connector AbortController map on the
class, wire stopConnector() to abort the current controller, and thread
the signal through both long sleeps in internalStartConnector. The
controller is recreated per invocation of internalStartConnector so a
stale abort from a previous run cannot short-circuit a fresh loop.

The polling helpers (waitChargingStationAvailable /
waitConnectorAvailable / waitRunningTransactionStopped) keep their
DEFAULT_ATG_WAIT_TIME_MS sleeps — they iterate on much shorter
timescales and are not the audit target.

Gates pass (format / typecheck / lint).

* fix(atg): validate min/max delay and duration bounds before starting the run loop

The AutomaticTransactionGenerator run loop draws its between-transaction
wait and in-transaction duration from randomInt(min, max + 1) of
node:crypto. That primitive throws RangeError when min >= max, so a
mis-configured template with minDelay > maxDelay or minDuration >
maxDuration kills the async loop at the first iteration with an
unhandled rejection that surfaces only as a generic 'Error while
starting connector' log line — the actual mis-config value is lost.

Add a private validateConfiguration() that checks both min/max
invariants up front and, on violation, logs a targeted error naming
the offending pair and refuses to schedule internalStartConnector.
Called from startConnector() before the internalStartConnector spawn
so external callers of startConnector() also benefit; start() reaches
startConnectors() → startConnector() and inherits the guard.

Config absence is not a violation (returns true); the run loop already
defaults to 0 for missing min/max fields and no randomInt call fires.

No behavior change for a valid configuration. Gates pass
(format / typecheck / lint).

* refactor(charging-station): remove 24 redundant restatement comments

The audit's M4-M6 finding covered ~63 imperative/redundant/narrative
comments across the codebase, roughly a quarter of which sat in
ChargingStation.ts as one-line WHAT-restatements directly above a
self-documenting method call:

  // Start heartbeat
  this.startHeartbeat()

or above a specifically-named event handler:

  // Handle WebSocket close
  this.wsConnection.on('close', this.onClose.bind(this))

Delete such restatements when the next line already conveys the same
information via method / event / status name. Comments that carry a
non-obvious WHY, a spec reference, a race condition note, or a subtle
behavioural invariant are preserved unchanged (e.g. the response-
handling deferred-promise reject-is-no-op explanation at line 2472).

24 comment lines removed; no code change beyond comment deletion.
Gates pass (format / typecheck / lint).

* refactor(ocpp): remove 10 redundant restatement comments in request/incoming request services

Continue the M4-M6 comment sweep in the OCPP request/incoming-request
dispatch layer:

  OCPPRequestService.ts (8 deletions)
    - // Send error message       (above sendError → internalSendMessage)
    - // Send response message    (above sendResponse → internalSendMessage)
    - // Build request            (above JSON.stringify of OutgoingRequest)
    - // Build response           (above JSON.stringify of Response)
    - // Send a message through wsConnection  (above the WebSocket Promise)
    - // Handle the request's response         (above responseHandler call)
    - // Remove request from the cache         (above requests.delete)
    - // Check if wsConnection opened          (above isWebSocketConnectionOpened())

  OCPPIncomingRequestService.ts (2 deletions)
    - // Log                       (above logger.error)
    - // Send the built response   (above sendResponse call)

The '// Build Error Message per OCPP-J §4.2.3: [4, messageId, errorCode,
errorDescription, errorDetails]' comment carries a spec reference and
the tuple shape, and stays. The '// Emit command name event to allow
delayed handling only if there are listeners' comment carries the
listener-count WHY and stays.

No code change. Gates pass (format / typecheck / lint).

* refactor(auth): remove 14 redundant restatement comments in OCPPAuthServiceImpl

Continue the M4-M6 comment sweep in the auth service. Delete pure
WHAT-restatement comments that immediately precede a self-descriptive
line (method call, property assignment, or self-explaining condition):

  - // Initialize metrics tracking / Initialize default configuration
    (above obvious property assignments in the constructor)
  - // Update request metrics / Update failure metrics
    (above metrics counter increments)
  - // Update metrics based on result
    (above updateMetricsForResult call)
  - // Create a minimal request to check applicability
    (above testRequest object literal)
  - // Check if adapter reports remote availability
    (above adapter.isRemoteAvailable() call)
  - // Merge new config with existing / Validate merged configuration
    / Apply validated configuration
    (updateConfiguration WHAT-narration)
  - // Check for specific error patterns that indicate critical issues
    (above criticalPatterns array literal)
  - // Track successful vs failed authentication / Track strategy usage
    / Track cache hits/misses based on method
    (updateMetricsForResult WHAT-narration)

Preserved for their WHY / non-obvious context:
  - // Note: Adapter and strategies will be initialized async via initialize()
    (async lifecycle note)
  - // Try each strategy in priority order (section marker in a 100+ line method)
  - // Continue to next strategy unless it's a critical error (has WHY)
  - // Get rate limiting stats from cache via remote strategy
    (describes routing indirection)
  - // Try local strategy first for quick cache/list lookup (fast-path rationale)
  - // Create a type guard to check if strategy has configure method
    / Use type guard instead of any cast (non-obvious TS pattern)

No code change. Gates pass (format / typecheck / lint).

* refactor(charging-station): remove 6 residual restatement comments in ATG and HelpersChargingProfile

Final M4-M6 comment sweep pass on the two remaining files with clear
pure-restatement comments:

  AutomaticTransactionGenerator.ts (2 deletions)
    - // Start transaction     (above await this.startTransaction(connectorId))
    - // Wait until end of transaction
      (above const waitTrxEnd = secondsToMilliseconds(randomInt(minDur, maxDur+1)) — the
       waitTrxEnd variable name is the comment)

  HelpersChargingProfile.ts (4 deletions)
    - // Check if the charging profile is active
      (above isWithinInterval(currentDate, {...}) condition)
    - // Check if the first schedule period startPeriod property is equal to 0
      (above the equality check on chargingSchedulePeriod[0].startPeriod)
    - // Handle only one schedule period
      (above chargingSchedule.chargingSchedulePeriod.length === 1 branch)
    - // Handle the last schedule period within the charging profile duration
      (above the composite last-period + duration-overflow condition)

Preserved WHY-comments in these two files: the § references, physics /
priority-order rationale, race-condition notes, and the ATG
interruptibleSleep + AbortController design comments introduced by
fa2bea32 / 5a066a51.

No code change. Gates pass (format / typecheck / lint).

* docs(atg): document onAbort cleanup semantics in interruptibleSleep

The empty JSDoc bloc above the onAbort function inside interruptibleSleep
was an auto-generated placeholder from the jsdoc/require-jsdoc lint rule
firing on function declarations. It read like a TODO left in place.

Replace with a targeted comment covering the two non-obvious invariants
this cleanup function relies on:
- clearTimeout(timeout) prevents the setTimeout callback from firing
  after the promise has already resolved on the abort path
- addEventListener({ once: true }) auto-removes the abort listener so
  no explicit removeEventListener is needed on the abort path

No behavior change. Gates pass (format / typecheck / lint).

* fix(physics): broaden voltageOut warning to Voltage.VOLTAGE_800 and harmonize with CoherentSession

Round-1 review of this PR surfaced a harmonization violation: my M13
warning at ChargingStation.getStationInfoFromTemplate covered only
Voltage.VOLTAGE_400 and gated on numberOfPhases===3, while the
pre-existing per-session warning at CoherentSession.createCoherentSession
covered Voltage.VOLTAGE_400 or Voltage.VOLTAGE_800 in AC without a phase
gate.

Broaden the station-init warning:
- Add Voltage.VOLTAGE_800 (common L-L nominal in DC HPC / industrial
  systems; users configuring it as L-N in AC produce sqrt(3) * 800
  ~= 1386 V simulated L-L).
- Drop the numberOfPhases === 3 gate so single-phase AC configurations
  with 400 V / 800 V voltageOut also surface at station init (matches
  CoherentSession coverage).
- Message shows the actual derived L-L (sqrt(3) * voltageOut) so users
  see the physical implausibility of both enum values.
- Suggestion 'set voltageOut=230' is emitted only for the 400 case;
  the 800 case has no clean L-N enum alternative (462 V is not in the
  Voltage enum), so the message stops at the diagnostic.

The two warning sites remain complementary:
- ChargingStation station-init: fires once per station lifetime,
  regardless of coherentMeterValues flag.
- CoherentSession per-session: fires per transaction, only when
  coherentMeterValues=true.

Users with coherentMeterValues=false now get consistent enum coverage
without depending on the session-level flag.

Gates pass (format / typecheck / lint).

* refactor(atg): memoize validateConfiguration result to dedupe per-connector log noise

Round-1 review noted that validateConfiguration() fires N times for a
station with N connectors (once per startConnector call inside the
startConnectors loop), so an invalid ATG configuration produces N
identical error log lines instead of one.

Add a private configurationValidationResult field cached on first
invocation and reset in stop() so a station with N connectors emits the
diagnostic log line at most once per session, while still allowing a
subsequent start() with a mutated configuration to re-validate from
scratch.

Cache lifecycle:
- Initialized to undefined in the constructor.
- Set to true or false on the first validateConfiguration() call after
  a start() (or after a fresh instance).
- Cleared back to undefined at the end of stop() so the next start()
  observes any config changes and re-emits the log line if invalid.

No behavior change for the valid-configuration path. For the invalid
path, log-line count drops from N (per-connector) to 1 (per-session).
Gates pass (format / typecheck / lint).

* refactor(utils): extract isRandomIntBoundsValid predicate and apply in ATG

Round-1 review noted that the min > max guard in
AutomaticTransactionGenerator.validateConfiguration is a specific
instance of a general concern: any caller of randomInt(min, max + 1)
from node:crypto risks a RangeError when configuration-driven min/max
values are mis-ordered.

Extract a pure predicate isRandomIntBoundsValid(min, max) into
src/utils/Utils.ts (co-located with other random primitives:
secureRandom, getRandomFloat, getRandomFloatFluctuatedRounded,
getRandomFloatRounded). The predicate carries the +1 semantics contract
in its JSDoc so consumers cannot misuse it. Zero logger dependency,
zero side effects, testable in isolation.

Apply in ATG's validateConfiguration where the same check now goes
through the named predicate for readability and reuse. The two log
lines remain in ATG with their full field-name context (which the
predicate intentionally does not embed to stay pure).

Other randomInt(min, max + 1) sites in the codebase remain unchanged,
each documented as low-risk:

- OCPP16IncomingRequestService.updateFirmwareSimulation lines 1860,
  1871, 1908, 1925, 1936, 1947: min/max are function-parameter
  defaults (maxDelay=30, minDelay=15, hardcoded and safe by
  construction); the internal callers respect the default ordering.
- OCPPServiceUtils.buildSocMeasurandValue line 270: socMaximumValue is
  the Constants.SOC_MAXIMUM_PERCENT (100), socMinimumValue is the Zod-
  schema-validated template field (bounded by the schema). The Zod
  schema guarantees the invariant at load time.

These sites can adopt the predicate in a follow-up config-validation
sweep PR if the constraints ever loosen. No behavior change in this
commit. Gates pass (format / typecheck / lint).

* fix(atg): guarantee AbortController cleanup and prevent per-connector race

Round-3 review (subagents #1 design/impl + #2 algo) surfaced two
concurrency bugs in internalStartConnector's per-connector controller
lifecycle:

1. Leak on abort/exception path: the delete at line 412 was only reached
   when the while-loop exited normally. Any await inside the loop that
   rejected (waitChargingStationAvailable, startTransaction,
   stopTransaction, etc.) propagated through the caller's .catch
   handler in startConnector — bypassing the delete and leaving the
   controller in connectorAbortControllers indefinitely.

2. Race in stopConnector→startConnector→cleanup ordering: if the old
   loop was still draining (e.g. resolving an aborted
   interruptibleSleep) when a new startConnector fired, the new run
   set(controller_new) at line 349, and shortly after the old loop
   reached the unconditional delete(connectorId), wiping the successor
   controller. A subsequent stopConnector could then not abort the new
   loop.

Fix both with a single try/finally + conditional delete pattern:

- try { while-loop } finally { conditional delete } guarantees
  cleanup on every exit path (normal, break, throw) without leaking.
- Delete only when this.connectorAbortControllers.get(connectorId)
  === abortController (identity check on the run-local reference)
  so a successor controller cannot be wiped by a predecessor's finally.

No behavior change on the happy path. Gates pass
(format / typecheck / lint).

* fix(atg): unconditionally reset validation cache in stop() to enable recovery

Round-3 review (subagents #1 + #2) found a stuck-state bug:

The cache reset was at the END of stop(), inside the branch guarded by
'if (!this.started) { return }'. So if a caller invoked
startConnector() directly (without going through start()), this.started
never became true, and any subsequent stop() early-returned without
resetting the cache — the user was stuck with a cached-false
configurationValidationResult and no path to recover.

Move the reset to the TOP of stop(), before the started/stopping
guards. The reset is idempotent (undefined -> undefined is a no-op),
so unconditional execution has no side effect on the already-stopped
path. This lets external startConnector() callers recover by calling
stop() to clear the cache.

Gates pass (format / typecheck / lint).

* refactor(utils): rename to isValidRandomIntBounds and strengthen input validation

Round-3 review (subagents #1 + #2 + harmonization #6) found three
issues with the extracted predicate:

1. Naming inversion — neighbors follow subject-first (isValidDate,
   isNotEmptyString, isNotEmptyArray) but isRandomIntBoundsValid put
   the operation before the subject. Rename to isValidRandomIntBounds.

2. Under-validation — the predicate only checked min <= max, so it
   returned true for NaN/Infinity/non-integers/negatives even though
   node:crypto.randomInt throws RangeError on all of them (safe integer
   0 <= min < 2^48 <= max required). NaN was accidentally rejected
   because 'NaN <= NaN' is false. Strengthen to reject explicitly:
   Number.isSafeInteger(minValue) && Number.isSafeInteger(maxValue) &&
   minValue >= 0 && minValue <= maxValue.

3. JSDoc leaked ATG-specific '+1' convention into a general utility
   contract. Trim @param maxValue to just 'Upper bound (inclusive).'
   The +1 semantics belong in the description (kept there) and at the
   call site.

Log messages in ATG updated to match:
- randomInt(min, max) -> randomInt(min, max + 1) to reflect the actual
  call
- Semicolon comma joining consequence clauses replaced with em-dash
  for readability

Gates pass (format / typecheck / lint).

* refactor(utils): export interruptibleSleep and apply to ATG polling helpers

Round-3 review (subagent #4 harmonization) found interruptibleSleep
was private to ATG and could not be reused, plus the three ATG
polling helpers (waitChargingStationAvailable / waitConnectorAvailable
/ waitRunningTransactionStopped) called plain sleep() and were blind
to the abort signal.

Changes:

1. Move interruptibleSleep from AutomaticTransactionGenerator.ts to
   src/utils/Utils.ts next to sleep(). Export from utils/index.ts.
2. Reorder to declare 'timeout' before the 'onAbort' arrow function so
   there is no forward reference (subagent #1 finding).
3. Expand JSDoc: describe both cleanup paths symmetrically (timer-path
   explicit removeEventListener, abort-path clearTimeout + { once: true }
   auto-removal). Also document that the promise resolves (does not
   reject) on abort so callers can drop try/catch noise.
4. Add signal: AbortSignal parameter to the three ATG wait helpers.
5. Replace sleep(DEFAULT_ATG_WAIT_TIME_MS) with
   interruptibleSleep(DEFAULT_ATG_WAIT_TIME_MS, signal) in each helper.
6. Include !signal.aborted in each while-loop condition so the poll
   exits immediately when the connector is stopped mid-wait.
7. Thread abortController.signal from internalStartConnector into all
   three helper calls.
8. Add 'interruptible' to cspell.config.yaml.

Impact: a stopConnector() call during an availability-wait now unblocks
the poll immediately rather than after DEFAULT_ATG_WAIT_TIME_MS. Gates
pass (format / typecheck / lint).

* fix(physics): scope L-L derivation to 3-phase and harmonize voltageOut warning

Round-3 review (subagents #1 impl elegance + #2 physics + #4
harmonization + #5 content) found the M13 warning had three related
issues:

1. Message inaccuracy for single-phase AC: the phrase '3-phase Y-derived
   line-to-line = X V' fired for single-phase AC stations too (F1 had
   intentionally dropped the numberOfPhases===3 gate for enum coverage
   symmetry with CoherentSession). But sqrt(3) only applies to balanced
   3-phase Y — for single-phase there is no L-L, so the number cited
   was physically meaningless.

2. Missing VOLTAGE_800 suggestion: only the VOLTAGE_400 case emitted a
   remediation hint. VOLTAGE_800 warned but did not tell the operator
   what to do next; 800 V L-L has no clean L-N enum equivalent, so the
   suggestion should say so.

3. Terminology drift vs CoherentSession: this site said
   'line-to-neutral' while CoherentSession said 'line-to-neutral (phase
   voltage)'; missing space before V in CoherentSession's log message.

Fix:
- Gate the 3-phase-Y clause behind getNumberOfPhases===3 (conditional
  string, still fires for all AC phase counts on enum match). For
  1-phase and DC, the derived-L-L clause is elided.
- Emit a VOLTAGE_800 remediation: 'no standard L-N enum value exists;
  verify template voltageOut intent (~462 V L-N)'.
- Adopt 'line-to-neutral (phase voltage)' terminology matching
  CoherentSession. Add space before V in CoherentSession log message.
- Trim the 10-line explanatory comment block to 6 lines: keep the
  invariant (voltageOut is L-N; VOLTAGE_400/800 commonly mis-configured);
  drop the cross-site harmonization prose (belongs in commit history).

Gates pass (format / typecheck / lint).

* docs(charging-profile): restore structural 'last period' navigation comment

Round-3 review (subagent #1 design/impl) found the M4-M6 comment sweep
had over-deleted one non-restatement comment at HelpersChargingProfile
line ~404: '// Handle the last schedule period within the charging
profile duration'. The composite condition below (last-in-array OR
next-period-would-exceed-duration) is not self-evident from the
boolean expression alone; the comment aids navigation through this
OCPP-specific boundary case.

Restore with a tighter version: 'Last period: last in array OR next
period would exceed the charging profile duration.'

No code change. Gates pass (format / typecheck / lint).

* docs(ocpp): use backtick-quoted `true` in @returns for consistency

Round-3 review (subagent #5 content finding 3) found @returns lines
in the M11 JSDocs used capitalized 'True when' while neighboring
JSDocs used backtick-quoted `true`. Normalize both:

- OCPPIncomingRequestService.isIncomingRequestCommandSupported
- OCPPResponseService.isRequestCommandSupported

No behavior change. Gates pass (format / typecheck / lint).

* docs: remove/trim redundant comments per DRY (AGENTS.md no-duplication rule)

User feedback: comments that repeat info already conveyed by log
messages, method names, or field names violate concision + AGENTS.md
'No duplication: maintain single authoritative documentation source;
reference other sources rather than copying'.

Changes:

1. ChargingStation.ts:1676-1681 - DELETE the 6-line voltage warning
   block comment. Every fact it stated (voltageOut is L-N, 400/800 are
   L-L nominals) is already in the log message immediately below
   ('matches a line-to-line nominal value', 'treats voltageOut as
   line-to-neutral (phase voltage)'). The 'harmonized with
   CoherentSession' cross-reference is trivially discoverable via
   'rg Voltage.VOLTAGE_400'.

2. AutomaticTransactionGenerator.ts stop() - trim 4-line 'Reset
   unconditionally' comment to 3 lines. Drop the redundant
   'configurationValidationResult' field name (visible on next line)
   and 'this.started never transitioned to true' (obvious from
   the guard structure).

3. AutomaticTransactionGenerator.ts stopConnector() - trim 5-line
   'Wake' comment to 3 lines. Drop the redundant second sentence about
   controller lifecycle (visible from the field name
   'connectorAbortControllers' and its usage in internalStartConnector).

4. AutomaticTransactionGenerator.ts internalStartConnector() - trim
   4-line 'Fresh AbortController' comment to 3 lines. The 'new
   AbortController()' on the next line already conveys freshness; only
   the abort-before-replace ordering (unblock lingering sleep) needs
   documenting.

5. AutomaticTransactionGenerator.ts try/finally - trim 5-line 'Only
   clear' comment to 2 lines. The try/finally structure self-documents
   the 'guarantees cleanup on all paths' claim; only the identity
   check for concurrent stopConnector→startConnector needs explanation.

No behavior change. Gates pass (format / typecheck / lint).

* fix(atg): close two ATG concurrency correctness gaps

Round-4 review (subagents #1 design/impl HIGH, #2 algo HIGH):

1. **Concurrent-loop race on back-to-back stopConnector→startConnector**
   (subagent #2 finding 3): after stopConnector aborts the controller,
   the old internalStartConnector loop resumes from its interruptible
   sleep. When startConnector fires immediately after and resets
   connectorStatus.start=true, the old loop's while condition (which
   only reads the shared 'start' flag) sees true again and continues
   running with an already-aborted signal — subsequent
   interruptibleSleep calls resolve synchronously → tight-loop, or
   worse, the old loop calls stopConnector again and aborts the NEW
   controller.

   Fix: add !abortController.signal.aborted to the while condition. The
   old loop now exits immediately after abort regardless of the shared
   'start' flag.

2. **waitRunningTransactionStopped stale connectorStatus snapshot**
   (subagent #1 finding 7): the helper fetched connectorStatus once
   before the loop and re-read the same object on each iteration. This
   works today because getConnectorStatus returns a Map reference that
   mutates in place, but the pattern was inconsistent with
   waitChargingStationAvailable / waitConnectorAvailable which call
   their predicates inline, and would silently break if
   getConnectorStatus ever returned a new object per call.

   Fix: inline the getConnectorStatus call in the while condition and
   inline it again for the one-time log message. Matches the pattern of
   the two sibling wait helpers.

Gates pass (format / typecheck / lint).

* fix(atg): move post-loop bookkeeping inside finally for exception paths

Round-4 review (subagent #1 design/impl MEDIUM finding 3): the
connectorStatus.stoppedDate assignment and the two 'Stopped on
connector' logger calls at the end of internalStartConnector sat
OUTSIDE the try/finally block. If any await inside the while loop
rejected (waitChargingStationAvailable, startTransaction, stopTransaction,
etc.), the finally block ran the controller cleanup — but the throw
propagated past the post-loop block, leaving connectorStatus.stoppedDate
undefined and skipping the 'Stopped on connector' + 'Stopped with
connector status' log lines. The connector observably appeared to be
still running for a stopped station.

Move the four post-loop statements inside the finally block so the
connector's stoppedDate is always set and the operator always sees the
'Stopped' log lines, regardless of exit path (normal exit, break,
throw). The ChargingStationEvents.updated emit also relocates so the
UI reflects the stopped state on every path.

Gates pass (format / typecheck / lint).

* docs: harmonize voltage warnings and @returns JSDoc across OCPP layer

Round-4 review (subagents #3 OCPP+TS, #4 harmonization, #5 content)
surfaced three harmonization drifts in JSDoc/log content:

1. ChargingStation.ts:1697 voltage warning said 'voltageOut=X V with
   AC output matches…' — 'with AC output' is redundant (the warning
   already gates on currentOutType === AC) and not spec terminology.
   Drop the qualifier.

2. CoherentSession.ts:103 voltage warning framed the diagnostic
   differently than ChargingStation.ts ("is treated as line-to-neutral
   by ACElectricUtils" vs "matches a line-to-line nominal value").
   Also attributed the interpretation to ACElectricUtils, an
   implementation detail that leaked into a user-facing log. Align
   framing to match ChargingStation.ts and drop the ACElectricUtils
   attribution.

3. AutomaticTransactionGenerator.ts:302-304 abort-before-replace
   comment ended with 'before installing the new controller' — the
   'new AbortController()' + 'connectorAbortControllers.set()' calls
   on the immediately following two lines restate that fact. Trim.

4. Four OCPP validate*Payload JSDocs used "@returns True if payload
   validation succeeds, false otherwise" — capitalized bare 'True'/'false'
   without backticks and no trailing period. Established pattern
   elsewhere in the same files uses "\ when … \ otherwise."
   Normalize in OCPPIncomingRequestService, OCPPRequestService,
   OCPPResponseService, and OCPPServiceUtils.

Gates pass (format / typecheck / lint).

* docs: polish batch from round-4 review

Round-4 subagents flagged five small content-quality items, none
blocking:

1. Utils.ts interruptibleSleep: delete two inline comment blocks that
   duplicate the JSDoc (timer-path cleanup and abort-path cleanup are
   already documented symmetrically in the JSDoc body).

2. Utils.ts isValidRandomIntBounds JSDoc: drop the last sentence
   that leaked call-site usage guidance into a general-utility
   predicate contract. Document the >= 0 constraint in @param.

3. AutomaticTransactionGenerator.ts validateConfiguration JSDoc: trim
   the memoization-mechanism sentence to a single clause. The full
   rationale already lives in the stop() reset comment.

4. AutomaticTransactionGenerator.ts validateConfiguration log messages:
   the > description understated the predicate rejection surface (it
   also rejects NaN/Infinity/non-integer/negative). Rewrite as invalid
   bounds matching the predicate contract.

5. Storage.ts three JSDoc @returns: backtick-quote void to match the
   codebase convention of quoting type names.

No behavior change. Gates pass (format / typecheck / lint).

* fix(ocpp/1.6): guard updateFirmwareSimulation against invalid delay bounds

Round-4 review (subagent #4 harmonization finding 3): six sites in
updateFirmwareSimulation call randomInt(minDelay, maxDelay + 1) using
function-parameter bounds. Current callers (constructor lines 586 and
596) pass no arguments so the safe defaults (maxDelay=30, minDelay=15)
apply. But the parameters are exposed, and any future caller passing
config-driven values could hit a randomInt RangeError inside the async
firmware simulation loop that surfaces only as a generic caught error.

Add an early isValidRandomIntBounds guard immediately after the
existing checkChargingStationState guard. On invalid bounds, log an
error naming the offending pair and return without scheduling any
simulation. Defensive-only — no behavior change for the current
default-only callers.

Gates pass (format / typecheck / lint).

* fix(ocpp/2.0): thread abort signal through simulateFirmwareUpdateLifecycle sleeps

Round-4 review (subagent #4 harmonization finding 1): the OCPP 2.0
firmware simulation lifecycle owns an AbortController
(activeFirmwareUpdateAbortController set at line 3703) so a concurrent
UpdateFirmware request can cancel an in-progress simulation (line
3091 aborts the previous controller). However, the eight
await sleep(...) calls inside simulateFirmwareUpdateLifecycle did not
observe that signal. The pattern was:

  await sleep(delayMs)
  if (checkAborted()) return

which correctly exits after abort but only ONCE the sleep expires. A
long sleep (retrieveDateTime scheduling, InstallDateTime scheduling)
could keep the simulation running for hours after the abort.

Replace each of the eight sleeps inside simulateFirmwareUpdateLifecycle
with interruptibleSleep(delay, abortController.signal). The abort now
wakes the sleep immediately; the subsequent checkAborted() guard
observes the aborted state and returns. Callback semantics identical
on the non-abort path.

The ninth sleep at line 3902 (simulateLogUploadLifecycle) is left
unchanged - that function does not own an AbortController and log
uploads are not user-cancellable in this simulator.

Gates pass (format / typecheck / lint).

* fix(utils): enforce node:crypto randomInt 2^48 range limit in predicate

Round-5 review (subagent OCPP+TS finding 8, HIGH): Number.isSafeInteger
allows values up to 2^53 - 1, but node:crypto.randomInt rejects any
call where max - min > 2^48 - 1 with a RangeError. The predicate's
documented contract ('safe for randomInt') was therefore incorrect
for wide ranges — the false-positive latent because current callers
use small integer delays (15-30s) and SoC bounds (0-100).

Add the 2^48 range check to the predicate and document it in the
JSDoc. Empirically verified against Node 22:
  randomInt(0, 2**48)   throws RangeError
  Number.isSafeInteger(2**48) === true

Gates pass (format / typecheck / lint).

* fix(ocpp): guard SOC randomInt against invalid template bounds

The SoC MeterValues fallback path in buildSocMeasurandValue drew a
random integer via randomInt(socMinimumValue, socMaximumValue + 1)
where socMinimumValue comes from the (user-supplied) template
minimumValue field and socMaximumValue is Constants.SOC_MAXIMUM_PERCENT
(=100). If a template set minimumValue >= 101, randomInt would throw
RangeError inside a MeterValues emission — surfacing only as a caught
error in the OCPP path.

Guard with isValidRandomIntBounds before the randomInt call. On
invalid bounds, log a warn line naming the offending pair and fall
back to socMaximumValue (100) so the measurand still produces a
value.

Gates pass (format / typecheck / lint).

* fix(atg): gate all post-loop bookkeeping on controller ownership

Round-5 review (design/impl finding 3, MEDIUM): the round-4 finally
block gated only the connectorAbortControllers.delete() on the
identity check (map.get === abortController). It then wrote
connectorStatus.stoppedDate unconditionally, emitted the Stopped log
lines, and fired the updated event.

On a back-to-back stopConnector -> startConnector, the sequence is:
  t0: old loop finally fires
  t1: identity check: map.get === NEW controller (the successor
       already installed by startConnector); check fails, delete
       correctly skipped
  t2: connectorStatus.stoppedDate = new Date()  <-- corrupts the
       new run's status object (shared reference in connectorsStatus)
  t3: 'Stopped on connector' log fires for a connector the operator
       just re-started

Gate all four post-loop operations (delete, stoppedDate, info log,
debug log, updated event) on a single isOwner boolean. Non-owner
paths silently exit — the successor run owns the status object and
will emit its own Stopped log when it eventually completes.

Gates pass (format / typecheck / lint).

* fix(ocpp/2.0): guarantee clearActiveFirmwareUpdate on every exit path

Round-5 review (harmonization finding C-2, MEDIUM):
simulateFirmwareUpdateLifecycle called clearActiveFirmwareUpdate only
on three specific paths (empty location, signature failure, happy
completion). Any other early-return path (7 abort returns) or a
thrown await left activeFirmwareUpdateAbortController set. A
subsequent UpdateFirmware.req would then abort() a non-existent
in-progress update, and stationState.activeFirmwareUpdateRequestId
would never re-populate — the station appeared stuck with a phantom
in-progress firmware update.

Wrap the entire body in try/finally with a single
clearActiveFirmwareUpdate call in finally. Delete the three now-redundant
site-local calls. Also correct the abort-controller store comment
from 'cancel' to 'abort' (codebase terminology finding B1/D4 from the
round-5 content review).

Gates pass (format / typecheck / lint).

* docs: polish batch from round-5 review

Four small content-quality items surfaced by round-5 review:

1. AutomaticTransactionGenerator.waitRunningTransactionStopped: the
   first-iteration log line called getConnectorStatus a second time
   (separate from the loop condition) to read transactionId. Between
   the two reads the transaction could theoretically end and a new
   one start, logging a different id than the one that triggered the
   wait. Snapshot transactionId into a local const before the log.

2. AutomaticTransactionGenerator.validateConfiguration error messages
   and OCPP16IncomingRequestService.updateFirmwareSimulation abort
   message both suffixed 'for randomInt(min, max + 1)' — misleading
   because isValidRandomIntBounds validates raw bounds, not the +1
   form. Drop the suffix; the 'invalid bounds' clause is sufficient.

3. CoherentSession voltage warning lacked the actionable remediation
   hint present in the ChargingStation counterpart. Append 'Set
   voltageOut to the L-N equivalent in the station template to
   resolve.'

No behavior change. Gates pass (format / typecheck / lint).

* fix(utils): tighten isValidRandomIntBounds boundary by one

The predicate validated maxValue - minValue < 2^48, but every call site
passes randomInt(min, maxValue + 1). node:crypto's randomInt requires
max_exclusive - min <= 2^48 - 1, so the effective constraint on the
inclusive bounds is maxValue - minValue <= 2^48 - 2. The old boundary
permitted maxValue - minValue = 2^48 - 1, which after the +1 offset
would throw RangeError at the randomInt call.

Practical impact is nil (all real config paths are far below the limit)
but the predicate is now nominally correct. Also trim JSDoc
overexplanation of caller convention.

* fix(ocpp): skip SoC measurand emission on invalid bounds

buildSocMeasurandValue previously fabricated a synthetic 100 % SoC
reading when the template's minimumValue was misconfigured (e.g. a
negative float that fails isValidRandomIntBounds). Emitting 100 %
looks like a valid meter reading to the CSMS and could distort
billing, session-end triggers, and analytics on the operator side.

Skip the measurand entirely instead — consistent with the existing
null-return path when the SoC template itself is absent (line 261).
The warn log still surfaces the misconfiguration.

* refactor(ocpp20): extract resetActiveFirmwareUpdateState helper

handleRequestUpdateFirmware's abort-and-replace path directly wrote
undefined to the two activeFirmwareUpdate* fields, while the lifecycle
finally routed through clearActiveFirmwareUpdate. Any future logic
added to the helper (logging, metrics, invariants) would silently
diverge for the abort-and-replace path.

Extract resetActiveFirmwareUpdateState as the single source of truth
for the field reset. clearActiveFirmwareUpdate still guards on
requestId identity (protecting a successor's state from being cleared
by a stale finally); handleRequestUpdateFirmware calls the reset
unconditionally because the abort-and-replace semantics require it.

* feat(atg): log displaced-loop exit at debug level

The isOwner gate silently returned from the finally block when a
concurrent stopConnector→startConnector had already installed a
successor controller. Diagnosing 'why did the old loop stop?' under
concurrency required knowing the silent path existed.

Emit a debug line on the non-owner path so displaced-loop exits are
observable without adding noise at higher log levels. Also trim the
redundant second half of the ownership comment (the consequence is
already implied by the WHY sentence above).

* docs(coherent): align voltage warning voice with ChargingStation

CoherentSession's L-L/L-N mismatch warning used passive voice
('If this value is meant as line-to-line') while the parallel warning
in ChargingStation.ts uses direct second-person ('If you intended').
Align to the second-person form for cross-file consistency.

* docs(utils): align isValidRandomIntBounds JSDoc notation

The description body stated 'maxValue - minValue <= 2^48 - 2' while
@param stated 'maxValue - minValue < 2^48 - 1'. Both are equivalent
for integers but forced a reader to verify equivalence. Also drop the
derivation showing how the node:crypto max_exclusive - min <= 2^48 - 1
constraint reduces to maxValue - minValue <= 2^48 - 2 — the derivation
belongs in commit history, not in the live docblock.

@param and description now use the same notation (<= 2^48 - 2).

* docs(logs): align tense and specificity in round-6 messages

Two log-message polish items surfaced by round-7:

ATG.internalStartConnector displaced-loop debug log used present
participle 'Exiting' while the sibling info log at line 391 uses past
tense 'Stopped'. Both fire from the finally block reporting a
completed event. Align to 'Exited'.

OCPPServiceUtils.buildSocMeasurandValue skip-log action clause read
'skipping measurand'. Sibling messages in ATG and OCPP16 use fully
qualified action noun phrases ('aborting connector startup',
'aborting firmware update simulation'). Add the 'SoC' qualifier for
parallelism: 'skipping SoC measurand'.

* refactor(ocpp20): harmonize firmware-state cleanup + log superseded

Three coherence gaps identified by round-7 harmonization review:

stop() aborted the active firmware update but wrote the delete without
routing through resetActiveFirmwareUpdateState. Latent-only because
the stationsState.delete makes the fields unreachable, but the pattern
diverged from every other cleanup site. Route through the helper.

clearActiveFirmwareUpdate silently returned when the requestId did not
match the active one — the exact 'displaced' pattern the ATG round-6
fix addressed. A stale finally firing after a successor took over is
diagnostically important. Emit a debug line for the superseded path.

Naming distinction (reset = unconditional, clear = identity-guarded)
is now self-documenting via the guard in clearActiveFirmwareUpdate.

* fix(ocpp20): prevent state resurrection in clearActiveFirmwareUpdate

getStationState is a lazy-init getter: on cache miss it creates an
empty entry and adds it to stationsState. clearActiveFirmwareUpdate
runs in the finally of simulateFirmwareUpdateLifecycle. If stop() has
already deleted the station entry before the aborted lifecycle's
finally fires, the lazy-init would resurrect a fresh empty entry —
leaking state past shutdown.

Use stationsState.get() directly and no-op silently when the entry is
absent. The non-owner debug log now only fires for genuine
supersession (state present, requestId mismatched).

* docs: revert isValidRandomIntBounds JSDoc to match code + fix typo

Round-7 aligned the JSDoc @param to '<= 2^48 - 2' while the code kept
'< 2 ** 48 - 1'. Both forms are equivalent for integers but the JSDoc
notation deviated from the code's literal. Revert JSDoc to '< 2^48 - 1'
which mirrors the code and stays close to node:crypto's own docs
phrasing ('range must be less than 2^48').

Also fix a pre-existing typo in the Constants.MAX_RANDOM_INTEGER
comment: 'randomInit()' -> 'randomInt()'.

* fix(ocpp20): use raw stationsState.get() in FirmwareStatusNotification trigger

The TriggerMessage/FirmwareStatusNotification case at line 564 is a
reporting path — it reads activeFirmwareUpdateRequestId to build the
notification payload but does not otherwise mutate station state.
Calling getStationState (which lazy-inits on cache miss) here means
a TriggerMessage arriving for a station that has never had a firmware
update creates a spurious empty state entry, and a TriggerMessage
arriving between stop() and WS close would resurrect state after
shutdown — same pattern as the round-8 clearActiveFirmwareUpdate fix.

Use stationsState.get() directly and optional-chain the requestId
read. When no state exists the notification carries requestId
undefined, which is spec-compliant for status = Idle (OCPP 2.0.1
L01.FR.20: requestId is mandatory only for non-Idle statuses).

* docs(utils): drop @param maxValue range-width restatement

The @param maxValue clause restated 'with maxValue - minValue <
2^48 - 1', which is already stated in the description body. Trim to
just the intrinsic bound (inclusive safe integer >= minValue) and
let the range-width constraint live where it belongs (the body).

* refactor(ocpp20): rename getStationState to getOrCreateStationState

The private getter lazily creates a station state entry on cache miss.
Callers seeing 'getStationState(cs)' vs 'stationsState.get(cs)' side
by side could not tell that the former creates state while the latter
does not — the very split rounds 8 and 9 introduced to fix
state-resurrection bugs (clearActiveFirmwareUpdate, TriggerMessage/
FirmwareStatusNotification).

Rename the lazy-init getter so its name carries the semantics:

  this.getOrCreateStationState(cs)  // creates on miss - handler paths
  this.stationsState.get(cs)        // undefined on miss - cleanup/reporting

Nine call sites + one definition. Zero behavior change.

* docs(utils): make @param minValue structurally symmetric with @param maxValue

Round-9 trimmed @param maxValue to 'safe integer >= minValue' but left
@param minValue as 'non-negative safe integer'. Both bounds now state
'safe integer >= X' — @param minValue relative to 0, @param maxValue
relative to minValue. The description body still carries the rejection
list (NaN, Infinity, floats, negatives, range-width).

* fix(ocpp20): cross-check firmware status against active requestId in trigger

FirmwareStatusNotification TriggerMessage read from two independent
sources: hasFirmwareUpdateInProgress reads chargingStation.stationInfo
.firmwareStatus, activeFirmwareUpdateRequestId lives on stationsState.
They can diverge in real windows:

1. simulateFirmwareUpdateLifecycle sets activeFirmwareUpdateRequestId
   at line 3716 before its first sendFirmwareStatusNotification (line
   3467). During any await between the two — notably the retrieveTime
   sleep at 3730 — a trigger would emit
   { requestId: X, status: Idle/undefined }.

2. On exception paths the finally resets activeFirmwareUpdateRequestId
   but leaves stationInfo.firmwareStatus at its last-set value. A
   trigger fired between the exception and the next UpdateFirmware.req
   would emit { requestId: undefined, status: <non-Idle> } — an OCPP
   2.0.1 L01.FR.20 violation (requestId mandatory for non-Idle).

Gate the non-Idle firmwareStatus branch on requestId being defined —
activeFirmwareUpdateRequestId is the authoritative signal for 'an
update is in progress'. When it is undefined we always emit Idle,
which is spec-compliant regardless of the stationInfo staleness.

* refactor(ocpp): extract getOrCreateWarnedMeasurands helper

The resolveEnabledMeasurands warn-once path had a 4-line inline
lazy-init block on warnedInvalidMeasurands (WeakMap<ChargingStation,
Set<string>>). Extract it as a module-level helper mirroring the
round-10 getOrCreateStationState naming.

Same semantic distinction as OCPP20IncomingRequestService:
- getOrCreate* — lazy-init, always returns a live reference
- .get() — raw WeakMap read, may return undefined

* fix(ocpp20): apply dual-gate to LogStatusNotification trigger

The LogStatusNotification TriggerMessage case sent
{ status: Idle } unconditionally, with no requestId — losing the
signal when a log upload is genuinely in progress and mirroring the
same pre-fix gap round-11 closed for FirmwareStatusNotification.

Round-11 established the dual-gate pattern: an active requestId in
stationsState is the authoritative 'operation in progress' signal;
the status is emitted from the same source of truth to guarantee
consistency between requestId and status.

Log upload has no stationInfo.logStatus equivalent (unlike firmware's
stationInfo.firmwareStatus), so store the status alongside the
requestId in the stationState. Wrap simulateLogUploadLifecycle in
try/finally to guarantee cleanup on exception. The trigger now
reports Uploading/Uploaded with the active requestId when in-flight,
Idle otherwise — matching the firmware pattern structurally.

* docs(ocpp20): enumerate all 8 in-progress statuses in hasFirmwareUpdateInProgress @returns

The JSDoc @returns listed 3 statuses (Downloading, Downloaded,
Installing) but the body checks 8 (also DownloadScheduled,
DownloadPaused, InstallScheduled, InstallRebooting, SignatureVerified).
Enumerate all 8 to match the implementation.

* fix(ocpp20): guard log-upload mid-lifecycle mutation + wrap firmware JSDoc

Two round-13 findings surfaced in the same file:

1. simulateLogUploadLifecycle mutated activeLogUploadStatus =
   Uploaded without an identity guard. If a concurrent GetLog
   request installed a new lifecycle during the sleep, the old
   lifecycle's post-sleep resume would overwrite the new lifecycle's
   status with a stale Uploaded, causing the TriggerMessage handler
   to report the wrong signal for the in-flight upload.

   Guard the mid-lifecycle status mutation with the same identity
   check used in the finally. Extract clearActiveLogUpload as a
   helper mirroring clearActiveFirmwareUpdate — same raw-.get() +
   null-guard + identity-check + debug-log-on-superseded pattern
   for observability parity with the firmware peer.

2. hasFirmwareUpdateInProgress @returns clause was 183 chars on one
   line and used unbacktick'd 'true'. Wrap the enumeration across
   three lines and restore the `true` backtick style established
   by 92a8dac1.

* fix(ocpp20): implement log-upload supersession per OCPP 2.0.1 N01.FR.12

Round-14 subagent C surfaced a HIGH spec-conformance gap: when a new
GetLog request arrives with a prior upload in progress, the CS was
returning Accepted and letting the old lifecycle run to completion
(sending a spurious Uploaded for the superseded requestId). This
violates N01.FR.12 + FR.20 and fails TC_N_36_CSMS step 7 which
expects LogStatusNotification(AcceptedCanceled) for the old id.

handleRequestGetLog now mirrors handleRequestUpdateFirmware:
- detect active upload, capture the previous requestId
- reset log state so the old lifecycle exits gracefully
- send LogStatusNotification(AcceptedCanceled) for the old id
- return GetLogResponse with status AcceptedCanceled

The GET_LOG on-hook now accepts both Accepted and AcceptedCanceled
to start the new lifecycle. The mid-lifecycle Uploaded emission is
now co-located inside the identity guard: a superseded lifecycle
skips both the state mutation AND the wire notification, preventing
the spurious Uploaded that would follow an already-emitted
AcceptedCanceled.

Extracted resetActiveLogUploadState mirroring
resetActiveFirmwareUpdateState (round-14 subagent A finding 2) so
clearActiveLogUpload delegates to the two-field wipe rather than
inlining it — full structural parity with the firmware peer.

* fix(ocpp20): round-15 clear-log parity + stop() log reset + JSDoc

Three findings from the same review round, one file:

1. clearActiveFirmwareUpdate and clearActiveLogUpload logged the debug
   'superseded requestId X (active: Y)' message even when the active
   field was undefined (the state was reset, not superseded). The
   post-supersession trace fires clearActive* with the old requestId
   after resetActive*State cleared the active field — the log said
   'active: undefined' which reads as a bug. Tighten the else branch
   to fire only when a DIFFERENT non-null requestId is active; drop
   the String() wrapper since only the number path can reach it.

2. stop() aborted the firmware controller and reset firmware state
   before deleting the stationsState entry, but skipped the log upload
   state. The delete makes the fields unreachable so it is not a
   memory leak, but the asymmetry breaks the structural mirror with
   firmware and the aborted-but-still-running log lifecycle would see
   inconsistent state post-stop. Add resetActiveLogUploadState for
   parity.

3. handleRequestGetLog @returns still said 'Accepted status' only —
   stale since the round-14 supersession fix. Extend to mention both
   Accepted (no prior upload) and AcceptedCanceled (superseded prior
   upload) with a description sentence on the supersession behavior.

* fix(ocpp20): cancel cert-signing retry timer in stop() + JSDoc polish

Three findings from round-16 review of the round-15 changes:

1. MEDIUM: OCPP20StationState.certSigningRetryManager holds a live
   setTimeout whose callback closes over chargingStation. The
   stationsState.delete() in stop() drops the WeakMap entry but the
   timer keeps chargingStation alive until it fires. Add
   certSigningRetryManager?.cancelRetryTimer() alongside the firmware
   abort() call, before the resets and the delete — closes the leak
   and matches the abort-before-reset pattern established for
   firmware.

2. LOW: 'simulates a Uploading' — wrong article. Fix to 'simulates
   an Uploading' (starts with vowel sound).

3. LOW: The @returns clause exceeded 140 chars on a single line.
   Wrap onto two lines to match the ~100-char convention.

* fix(ocpp20): add AbortController to log-upload lifecycle + canceled spelling

Round-17 findings from rounds 13-17 flagged the log-upload/firmware
structural asymmetry repeatedly: firmware had AbortController + reset,
cert-signing had cancelRetryTimer + reset, log had only identity-guard
+ state reset. A superseded lifecycle woke from sleep on its own
schedule, wasted the delay, and the aborted-but-still-running
coroutine emitted spurious wire notifications until the identity
guard caught it. Round-17's harmonization subagent explicitly said
'worth fixing in this PR while the pattern is fresh'.

Add activeLogUploadAbortController to OCPP20StationState, create it
in simulateLogUploadLifecycle, replace sleep() with interruptibleSleep
threading the signal, and gate the mid-lifecycle status write and
send on both signal.aborted and the identity check. abort() the
controller in handleRequestGetLog supersession, in stop(), and reset
it via resetActiveLogUploadState. sleep import is now unused —
removed.

Also fix pre-existing British/American spelling drift surfaced by
round-17 content review: 'Retry timer cancelled' -> 'canceled', for
consistency with the cancelRetryTimer method name and OCPP enum
values (AcceptedCanceled).

3 weeks agofix(ocpp): from-zero audit — Critical + High spec conformance and safety fixes (...
Jérôme Benoit [Mon, 6 Jul 2026 17:33:06 +0000 (19:33 +0200)] 
fix(ocpp): from-zero audit — Critical + High spec conformance and safety fixes (#1961)

Eleven commits from the from-zero audit (three review rounds).

Fixes:
- C2 (§4.2) forbid non-triggered outbound messages in Pending state under non-strict compliance
- H2 emit StatusNotification(Finishing) before StopTransaction.req in the non-remote stop path
- H6 (§5.11) RemoteStart auto-select skips connectors reserved for a different idTag
- H9 partial: .unref() on 5 lifecycle timer sites (3 setTimeout + 2 setInterval) so they no longer block node.js shutdown; per-site safety-invariant comments
- H10 partial: initialize 5 string ChargingStation fields in the constructor; drop their definite-assignment markers
- H11 log the errors previously swallowed by .catch(() => undefined) in AbstractUIServer

Review follow-ups:
- Correct method name in stop() metrics-cleanup log prefix
- Clarify runMetricsScrape catch-log wording
- Correct spec-citation in stopTransactionOnConnector test title

Gates: format / typecheck / lint / build / test (2951 pass / 0 fail / 6 skipped) all pass at tip ca6b0182. Skott circular-dependency count preserved at 61.

Empirically-rescinded audit findings: H4, H5, H8.
Deferred with concrete rationale: H7, H9 residual, H10 residual.

3 weeks agorefactor: audit-driven cleanup — OCPP conformance fixes, physics, harmonization ...
Jérôme Benoit [Mon, 6 Jul 2026 16:09:30 +0000 (18:09 +0200)] 
refactor: audit-driven cleanup — OCPP conformance fixes, physics, harmonization (#1960)

* fix(ocpp/1.6): correct GetDiagnostics log directory resolution

`handleRequestGetDiagnostics` called `resolve()` with the comma operator
by accident:

  resolve((fileURLToPath(import.meta.url), '../', dirname(logFile)))

The extra parentheses wrap a comma-operator expression that evaluates to
`dirname(logFile)` only, so `readdirSync` was invoked with a
directory resolved from CWD instead of relative to the module URL. When
the process CWD is not the project root (e.g. a systemd service, a docker
container that mounts logs elsewhere, a background worker with a chdir'd
parent), the log-file discovery scanned the wrong directory and produced
an incomplete diagnostics archive.

Align with the sibling call at line 1183 which already uses the intended
pattern:

  resolve(dirname(fileURLToPath(import.meta.url)), '../', diagnosticsArchive)

Both call sites now resolve paths relative to the module's parent
directory. Gates pass (typecheck / lint).

* fix(physics): use sqrt(3) for line-to-line voltage derivation

Two sites derived the line-to-line nominal voltage from Math.sqrt(numberOfPhases) * V_LN:

- src/charging-station/ocpp/OCPPServiceUtils.ts:1255
- src/charging-station/meter-values/CoherentMeterValueBuilder.ts:282

The ratio V_LL / V_LN in a balanced 3-phase Y system is a fixed sqrt(3),
which comes from the 30-degree phase separation between line and neutral
voltages (V_LL = 2 * V_LN * sin(60 deg) = sqrt(3) * V_LN). It is NOT a
function of the phase count.

Both call sites are currently reachable only when numberOfPhases === 3
(one guarded explicitly at CoherentMeterValueBuilder.ts:281; the other
implicitly via the phaseLineToLineVoltageMeterValues + phase-rotation
plumbing that only supports 3-phase). Numerically the emitted value is
unchanged, but the formula was a trap: any future relaxation of the
3-phase guard would silently emit sqrt(2) * V_LN at N=2, which is
physically meaningless.

Replace both call sites with Math.sqrt(3) and add a code comment stating
the physics derivation so the constant cannot be re-parameterized on the
phase count.

Gates pass (typecheck / test, 2955 pass / 0 fail).

* refactor(auth): standardize on OCPP 2.0.1 terminology in auth subsystem

The recently-added auth subsystem used bare 'OCPP 2.0' throughout its
comments and JSDoc while the rest of the codebase (README, AGENTS.md,
all other source files) uses 'OCPP 2.0.1' as the canonical spec version
and 'OCPP 2.0.x' as the family. The auth subsystem also used non-existent
forms 'OCPP 2.0+' and 'OCPP 2.0/2.1' that misrepresented the supported
spec range: OCPP 2.1 is not implemented.

Bulk-replace across src/charging-station/ocpp/auth/:
- 'OCPP 2.0+'   -> 'OCPP 2.0.1'
- 'OCPP 2.0/2.1' -> 'OCPP 2.0.1'
- bare 'OCPP 2.0' -> 'OCPP 2.0.1'  (negative lookahead on '.digit' so
  existing 'OCPP 2.0.1' and 'OCPP 2.0.x' occurrences are preserved)

65 occurrences updated across 5 files. Files touched:
- adapters/OCPP20AuthAdapter.ts   (31)
- types/AuthTypes.ts              (21)
- strategies/CertificateAuthStrategy.ts (7)
- utils/AuthValidators.ts         (5)
- strategies/RemoteAuthStrategy.ts (1)

Zero code behavior change; comment/JSDoc content only.
Gates pass (typecheck / lint).

* refactor(ocpp/1.6): harmonize 'Central System' capitalization for logs and docs

Two mismatches surfaced against OCPP 2.0 counterparts:

1. `csmsName` field values in OCPP16IncomingRequestService.ts:176 and
   OCPP16ResponseService.ts:129 held the lowercase two-word form
   'central system'. The abstract base classes interpolate this field
   into runtime error messages, producing 'not registered on the central
   system' for OCPP 1.6 versus 'not registered on the CSMS' for OCPP
   2.0. Two spec-correct forms are involved (OCPP 1.6 uses 'Central
   System'; OCPP 2.0.1 uses 'CSMS' for 'Charging Station Management
   System'), so keep the spec-correct term per version but capitalize
   the 1.6 value to match OCPP 1.6 spec convention.

2. JSDoc prose across OCPP 1.6 files ('sends X to the central system',
   'response from the central system', etc.) mixed the lowercase form
   with the capitalized 'Central System (CS)' used in the module-level
   docstring of OCPP16IncomingRequestService.ts. Normalize to the
   capitalized form throughout src/charging-station/ocpp/1.6/.

Zero code behavior change; only logged strings and comment prose shift
casing. Gates pass (typecheck / lint).

* refactor: apply numeric-literal underscore separators to 5-digit-plus constants

Numeric-literal style was inconsistent: OCPP20Constants.ts uses
underscore separators for readability (e.g. `30_000`, `5_000`),
while src/utils/Constants.ts and other constants files used bare
literals (`60000`, `30000`, `1048576`).

Apply underscore separators uniformly to 5+ digit numeric literals
across the constants files, matching the OCPP20Constants.ts precedent:

- src/utils/Constants.ts: `60000` -> `60_000` (6 sites),
  `30000` -> `30_000`, `281474976710655` -> `281_474_976_710_655`,
  `2147483647` -> `2_147_483_647`.
- src/charging-station/ocpp/OCPPConstants.ts:
  `OCPP_WEBSOCKET_TIMEOUT_MS = 60000` -> `60_000`.
- src/charging-station/ui-server/UIServerSecurity.ts:
  `DEFAULT_MAX_PAYLOAD_SIZE_BYTES = 1048576` -> `1_048_576`,
  `DEFAULT_RATE_WINDOW_MS = 60000` -> `60_000`,
  `DEFAULT_MAX_TRACKED_IPS = 10000` -> `10_000`.

4-digit values (1000, 3600, 5000, 8080) are intentionally left unchanged;
they include port literals (`DEFAULT_UI_SERVER_PORT = 8080`) where the
underscore form is unusual. AGENTS.md numeric-literal style is a
readability convention rather than an enforced rule; applying it only
above the 5-digit threshold matches the existing OCPP20Constants.ts
pattern.

Zero code behavior change. Gates pass (typecheck / lint / test, 2955
pass / 0 fail).

* chore: expose WILDCARD_HOSTS + document Authorize in OCPP 2.0.x README table

Two low-risk harmonization findings from the from-zero audit.

1. README OCPP 2.0.x commands table missing 'Authorize' (Lane 5 M2)
   The 'Authorize' command is fully implemented for OCPP 2.0.x:
   - OCPP20RequestCommand.AUTHORIZE registered
   - OCPP20RequestService.ts / OCPP20ResponseService.ts handlers wired
   - OCPP20ServiceUtils.ts sending helper
   but the README '#### C. Authorization' section listed only 'ClearCache'.
   Add ':white_check_mark: Authorize' alongside 'ClearCache' so the docs
   match the actual implementation surface.

2. Extract WILDCARD_HOSTS set (Lane 4 M20)
   AbstractUIServer.ts:1325 inlined the three literals '', '0.0.0.0', '::'
   as a triple '===' chain to detect wildcard host configuration, while
   UIServerAccessPolicy.ts:23 already held the same three values in a
   module-scoped 'WILDCARD_HOSTS' set. Two independent definitions of
   the same domain concept.
   Export WILDCARD_HOSTS (typed 'ReadonlySet<string>') from
   UIServerAccessPolicy.ts, import it in AbstractUIServer.ts, and
   replace the inline OR chain with 'WILDCARD_HOSTS.has(configuredHost)'.

Zero behavior change. Gates pass (typecheck / lint).

* fix(ocpp/1.6): return Accepted for DataTransfer with matching vendor and messageId

Per OCPP 1.6 §4.3, the DataTransfer response statuses are:
- `UnknownVendorId` when the vendorId is not recognized
- `UnknownMessageId` when the vendorId is recognized but the messageId
  is not
- `Accepted` when both vendor and message are recognized

`handleRequestDataTransfer` previously returned `UnknownMessageId` for
*any* non-null messageId, even when the vendorId matched the station's
configured `chargePointVendor`:

    if (vendorId !== chargingStation.stationInfo?.chargePointVendor) {
      return ... UNKNOWN_VENDOR_ID
    }
    if (messageId != null) {
      return ... UNKNOWN_MESSAGE_ID     // wrong: any non-null messageId
    }

That rejected every legitimate DataTransfer with a messageId, regardless
of whether the messageId would have been supported. The simulator does
not maintain a per-vendor messageId registry (it has no way to know
which custom messageIds a real charge point would accept), so the
spec-correct behaviour is to accept any messageId once the vendor
matches.

Drop the erroneous `messageId != null` branch and return `Accepted`
whenever the vendor matches; the `UnknownVendorId` path is unchanged.
Add a source comment citing OCPP 1.6 §4.3 to prevent regression of the
buggy check.

Test update: the existing 'should return UnknownMessageId for matching
vendor with messageId' test asserted the old buggy behaviour. Rewritten
as 'should return Accepted for matching vendor with messageId (no
messageId registry per §4.3)' to lock in the spec-correct outcome.

Gates pass (typecheck / test, 2955 pass / 0 fail).

* fix(ocpp/1.6): emit FirmwareStatusNotification(Installed) after Installing

Per OCPP 1.6 §4.5, the Charge Point SHALL notify the CSMS of firmware
update progress via FirmwareStatusNotification. `updateFirmwareSimulation`
already emitted the intermediate statuses (Downloading -> Downloaded ->
Installing) but never signalled successful completion — after emitting
`Installing`, the simulation went straight to the optional reset step
(if configured) or returned, leaving the CSMS with no confirmation
that installation actually finished.

Insert an `Installed` status emission between the InstallationFailed
early-return branch and the optional reset:

    sleep(...)
    requestHandler(FIRMWARE_STATUS_NOTIFICATION, { status: Installed })
    stationInfo.firmwareStatus = Installed
    if (reset === true) { sleep(...); reset() }

Behaviour on the failure path is unchanged: the InstallationFailed
branch still returns early without emitting `Installed`. Behaviour
when `reset` is disabled is unchanged apart from the additional
notification (which is the whole point of the fix).

Gates pass (typecheck / test, 2935 pass / 0 fail).

* fix(ocpp/1.6): return Failed (not NotSupported) when LocalAuthList is disabled or manager missing

Per OCPP 1.6 §5.15, the SendLocalList response statuses have distinct
semantics:
- NotSupported: the LocalAuthListManagement feature profile is not
  implemented by the Charge Point
- Failed: the feature is implemented but the specific update failed
  (disabled, unavailable, or otherwise unable to apply the change)

`handleRequestSendLocalList` correctly returned NotSupported when the
LocalAuthListManagement profile is absent (line 1534). But two
subsequent guards also returned NotSupported when:
- `LocalAuthListEnabled` config is false (feature supported but disabled)
- `getLocalAuthListManager()` returns null (feature supported but manager
  not wired)

Both scenarios describe a Charge Point that supports the feature — the
LocalAuthListManagement profile is present — but cannot apply this
particular update. Per spec, that is Failed, not NotSupported.

Change both guards to return
`OCPP16Constants.OCPP_SEND_LOCAL_LIST_RESPONSE_FAILED`. The profile-
absent guard (line 1534) is unchanged — it correctly reports
NotSupported when the feature is not implemented at all.

Test updates: two existing tests locked in the old NotSupported result.
Renamed and updated to assert Failed with an inline reference to §5.15
so the spec-correct outcome is regression-locked.

Gates pass (typecheck / test, 2945 pass / 0 fail).

* chore(auth): remove OCPPAuthServiceImpl from public barrel

`OCPPAuthServiceImpl` is the concrete implementation of the
`OCPPAuthService` interface. External code should construct instances
through `OCPPAuthServiceFactory` (which internally uses the class)
rather than instantiating it directly. Exporting the concrete class
from the auth barrel published an implementation detail that:

- Adds public API surface with no consumer benefit
- Encourages callers to bypass the factory's per-station singleton
- Complicates future refactoring of the concrete class

Grep across src/ and ui/ confirms zero consumers of
`OCPPAuthServiceImpl` via the barrel (`ocpp/auth/index.js`); the
factory imports it via the direct file path within the same subtree.
Two test files (`OCPP20ResponseService-CacheUpdate.test.ts`,
`OCPP20ServiceUtils-AuthCache.test.ts`) did import it via the barrel;
routed to the direct path so they still exercise the concrete class
where explicitly needed.

Also correct the module-level JSDoc from 'OCPP 1.6 and 2.0' to
'OCPP 1.6 and 2.0.1' to match the canonical spec-version convention
used throughout the codebase.

Gates pass (typecheck / lint / test, 2955 pass / 0 fail).

* refactor: extend OCPP 2.0.1 terminology to non-auth paths and compress physics comment

Two round-3 review findings from PR #1960 self-review addressed together.

1. Round-3 High: terminology scope creep

Prior commit dbc3f3ff bulk-replaced OCPP 2.0 -> OCPP 2.0.1 in
src/charging-station/ocpp/auth/ only (65 occurrences). Identical drift
existed across the rest of the OCPP 2.0.x code paths, including the
invalid OCPP 2.0+ form that dbc3f3ff had explicitly corrected in auth/
but left intact in ~10 sites of OCPP20IncomingRequestService JSDoc.
After dbc3f3ff, auth/ said OCPP 2.0.1 while the rest of the OCPP 2.0.x
subtree still said OCPP 2.0 / OCPP 2.0+ — the terminology divergence
was worse, not better.

Apply the same three-pass perl regex (strip +, collapse /2.1,
negative-lookahead on .digit for bare 2.0) to:

- src/charging-station/ocpp/2.0/**/*.ts               (56 occurrences)
- src/charging-station/ocpp/OCPPServiceUtils.ts       (3)
- src/charging-station/ui-server/UIMCPServer.ts       (2)
- ui/common/tests/payloadBuilders.test.ts             (2)

63 total substitutions. Zero code behavior change; comment/JSDoc/test
description content only. Zero remaining bare OCPP 2.0 refs under the
scanned scope.

2. Round-3 Low: physics comment verbosity

The sqrt(3) physics anchor comment added in commit 34a574f9 spanned 8
lines with tangential explanations. Compress to 3 lines that keep the
essential physics anchor (V_LL = sqrt(3) * V_LN, 30-degree phase
separation, 3-phase-only constraint).

Documented as still-deferred:
- Narrative comment at OCPP16IncomingRequestService.ts:276 — kept under
  M5 bulk cleanup deferral.
- OCPP 2.0.x handleRequestDataTransfer UnknownVendorId design — a
  different intentional choice, not the same bug as OCPP 1.6 C3.

All gates green (format / typecheck / lint / build / test) across root,
ui/cli, ui/web. Circular-deps baseline of 61 preserved.

* chore: complete OCPP 2.0.1 terminology sweep and drop redundant physics comment line

Round-4 review findings from PR #1960 self-review addressed together.

1. Terminology sweep completion (Low)

The round-3 extension (commit e03368ff) covered
src/charging-station/ocpp/2.0/**, OCPPServiceUtils.ts, UIMCPServer.ts,
and payloadBuilders.test.ts, but the audit's terminology finding
applied to the whole codebase. Four bare 'OCPP 2.0' refs survived in
charging-station/ files outside ocpp/2.0/:

- src/charging-station/ConfigurationKeyUtils.ts:
  user-facing warning 'use OCPP 2.0 variable ... instead'
- src/charging-station/meter-values/CoherentMeterValueBuilder.ts:
  code comment 'Narrow the OCPP 2.0 SampledValueTemplate.unit ...'
- src/charging-station/TemplateSchema.ts (2 sites):
  JSDoc references to 'OCPP 2.0 unit-of-measure descriptor' and
  'OCPP 2.0 namespaced keys'

Apply the same negative-lookahead perl regex; 4 substitutions, zero
code behaviour change. The user-facing warning in ConfigurationKeyUtils
now cites the spec version consistently with the rest of the codebase.

2. Physics comment redundancy (Info)

The compressed sqrt(3) anchor comment from commit e03368ff sat
directly below a preexisting one-liner ('Line-to-line voltage is only
defined for 3-phase AC') that duplicated the L-L === 3-phase constraint
already stated in the compressed block ('Defined only for numberOfPhases
=== 3'). Delete the preexisting redundant line; the compressed anchor
carries the full physics rationale (V_LL = sqrt(3) * V_LN, 30-degree
phase separation, 3-phase-only constraint).

Test files (~15 refs of 'OCPP 2.0' as describe/it group labels) are
intentionally left as-is: those are test-grouping labels rather than
spec-version references, and renaming them would break dev-side test
regex filters without functional benefit.

Gates green (format / typecheck / lint / build / test) across root;
ui/cli and ui/web untouched. Circular-deps baseline of 61 preserved.

3 weeks agorefactor: audit-driven cleanup (OCPP 2.0 spec conformance + 5 low-risk refactors...
Jérôme Benoit [Mon, 6 Jul 2026 14:14:42 +0000 (16:14 +0200)] 
refactor: audit-driven cleanup (OCPP 2.0 spec conformance + 5 low-risk refactors) (#1959)

* fix(ocpp/2.0): correct spec conformance for value size constants

OCPP 2.0.1 mandates two distinct value size caps that the codebase collapsed
into a single MAX_VARIABLE_VALUE_LENGTH = 2500 constant, which caused the
SetVariable code path to accept payloads up to 2.5x the spec-defined limit
(§2.1.20: ConfigurationValueSize.maxLimit = 1000; SetVariableDataType.attributeValue
is string[0..1000]) and mislabeled the reporting cap (§2.1.21: ReportingValueSize.maxLimit = 2500).

Replace MAX_VARIABLE_VALUE_LENGTH with two spec-cited constants:
- MAX_CONFIGURATION_VALUE_SIZE = 1000 (SetVariable path, §2.1.20)
- MAX_REPORTING_VALUE_SIZE     = 2500 (GetBaseReport / reporting truncation, §2.1.21)

Remap all consumers:
- OCPP20VariableRegistry: ConfigurationValueSize maxLimit -> MAX_CONFIGURATION_VALUE_SIZE;
  ReportingValueSize + OCPP 2.1 ValueSize maxLimit -> MAX_REPORTING_VALUE_SIZE.
- OCPP20VariableManager readAttributeValue reporting truncation -> MAX_REPORTING_VALUE_SIZE.
- OCPP20VariableManager setVariable effective-limit fallback + absolute cap ->
  MAX_CONFIGURATION_VALUE_SIZE.
- Test fixtures resetReportingValueSize / resetValueSizeLimits use each spec constant
  according to which variable they seed.

Public API surface is not affected (constants are internal to ocpp/2.0).

* refactor: dedupe zod field-error mapping, centralize ui/web skin/theme keys

Three independent cleanups from the from-zero audit, bundled to share quality gates.

1. Extract shared Zod-issue to FieldError helpers (z-A1-F1)
   Two identical inline duplications (in ConfigurationValidation and
   TemplateValidation) mapped ZodError.issues to { message, path } records
   and joined them into the same '  - <path>: <message>' field summary.
   Extract two exported helpers in ConfigurationMigrations:
   - mapZodIssuesToFieldErrors(zodError)
   - formatFieldErrorsSummary(fieldErrors)
   Update both call sites; TemplateValidationError.fieldErrors is retyped
   from an inline object array to the shared FieldError (structurally
   identical, public surface preserved).

2. Centralize ui/web skin/theme storage keys in core/Constants (z-A2-F3)
   SKIN_STORAGE_KEY, DEFAULT_THEME, and THEME_STORAGE_KEY previously lived
   in the composables (useSkin.ts, useTheme.ts) even though main.ts and
   SkinLoadError.vue imported them from there for storage bootstrap,
   mirroring the already-centralized DEFAULT_SKIN. Move all three to
   ui/web/src/core/Constants.ts, re-export via the @/core barrel, and update
   the composables plus the two external consumers to import from @/core.
   Behaviour and localStorage keys are unchanged.

3. Adopt ui-common extractErrorMessage at 3 sites (z-A2-F4)
   Replace three copies of the 'error instanceof Error ? error.message :
   String(error)' pattern with extractErrorMessage(error) (ui-common already
   used this helper in ui/cli/src/config/loader.ts, output/json.ts,
   output/formatter.ts, and ui/web/src/skins/modern/utils/errors.ts):
   - ui/cli/src/commands/action.ts
   - ui/cli/src/commands/skill.ts
   - ui/web/src/shared/composables/useSkin.ts

Rationale for a deferred audit item (z-A1-F2, UIServerSecurity DEFAULT_*
constants to global Constants): declined. The six constants
(DEFAULT_MAX_PAYLOAD_SIZE_BYTES, DEFAULT_RATE_LIMIT, DEFAULT_RATE_WINDOW_MS,
DEFAULT_MAX_STATIONS, DEFAULT_MAX_TRACKED_IPS,
DEFAULT_COMPRESSION_THRESHOLD_BYTES) are ui-server-security tunables used
only within src/charging-station/ui-server/. Promoting them into the
charging-station-wide Constants class would blur module boundaries with
no consumer benefit; co-location with the security domain is intentional.

Public API surface is unchanged. All gates pass (format / typecheck / lint /
build / test) across root, ui/cli, and ui/web; skott circular-deps baseline
of 62 preserved.

* refactor(ui/web): align default-skin fallback and extract host:port literal

Two independent slop cleanups in ui/web from the from-zero audit.

1. Use canonical DEFAULT_SKIN in place of the 'classic' literal fallback (z-A2-F6)
   `ui/web/src/main.ts:62` used a hardcoded 'classic' as the fallback for
   `config.skin ?? 'classic'` even though the codebase declares the canonical
   default in `ui/web/src/core/Constants.ts`:
     export const DEFAULT_SKIN = 'modern'
   and `SkinLoadError.vue` already uses DEFAULT_SKIN as the recovery target.
   The magic string kept the two out of sync: a fresh boot with neither
   localStorage nor config.skin would land on 'classic' while the rest of
   the app treated 'modern' as the default. Route the fallback through
   DEFAULT_SKIN so there is a single source of truth for the default skin.

   Behavior change (intentional): first boot with no persisted skin and no
   `config.skin` now initializes on 'modern' (the declared default) instead
   of 'classic'. Users who explicitly set a skin (via config or the skin
   selector) are unaffected because their choice is honored before the
   fallback kicks in.

2. Extract 'host:port' literal in UIClient WebSocket adapter (z-A2-F7)
   Three interpolations of `${config.host}:${config.port.toString()}` in the
   onerror/onopen handlers of `createClientWithAbort` are collapsed into a
   single `uiServerAddress` local computed once at the top of the closure.
   Zero behavior change; just removes the triplicated interpolation.

Skipped audit item (z-A2-F5, adopt `nonEmptyStringOrUndefined` in
`useSetUrlForm` for supervisionUser/supervisionPassword): declined.
Per the WebSocket protocol contract documented in README.md ('setSupervisionUrl'
procedure), an empty string `""` for user/password explicitly clears the
existing CSMS auth, while `undefined` preserves it. Wrapping the form values
with `nonEmptyStringOrUndefined` would silently change the UX: leaving a
password field blank would preserve the old password instead of clearing it,
with no user-facing indication of the change. Keeping the raw pass-through
matches the documented protocol semantics.

Gates green (format / typecheck / lint / build / test:coverage) for ui/web;
root and ui/cli untouched.

* refactor: prune unused barrel exports and downgrade internal-only symbols

Five symbols were exported from src/utils/index.ts and src/types/index.ts but
have zero consumers in src/, tests/, ui/common/, ui/cli/, or ui/web/. Prune
them from the barrels; since each is also unused outside its defining file,
downgrade the file-level 'export' keyword so the internal-only status is
enforced at compile time (except ElementsPerWorkerType which had no consumer
at all and is fully removed).

Pruned barrel exports (root package has no library API surface — package.json
'exports' points only to dist/start.js — so unused barrel entries are dead):

- DEFAULT_PERSIST_STATE (src/utils/index.ts):
  used only inside Configuration.ts:196; barrel entry removed; definition
  downgraded from 'export const' to 'const'.
- UIServerAccessPolicySchema (src/utils/index.ts):
  used only inside ConfigurationSchema.ts:243 as .optional() nested schema;
  barrel entry removed; 'export const' -> 'const'.
- UIServerAuthenticationSchema (src/utils/index.ts):
  used only inside ConfigurationSchema.ts:244 as .optional() nested schema;
  barrel entry removed; 'export const' -> 'const'.
- AtomicWriteOptions (src/utils/index.ts):
  used only inside FileUtils.ts as parameter type for atomicWriteFile /
  atomicWriteFileSync; barrel entry removed; 'export interface' -> 'interface'.
- ElementsPerWorkerType (src/types/index.ts + ConfigurationData.ts):
  no consumer anywhere; both barrel entry and type alias definition removed.

Kept (audit misclassified as dead):
- isCertificateBased, isOCPP16Type, isOCPP20Type, requiresAdditionalInfo:
  all four exercised by tests/charging-station/ocpp/auth/types/AuthTypes.test.ts
  — pruning would drop test coverage.
- StrictTemplateSchema (src/charging-station/index.ts):
  documented escape hatch ('For CI strict mode' per TemplateSchema.ts:309);
  intentional public surface for ad-hoc strict validation with no in-repo
  consumer. Kept to honor documented intent.

All gates pass (format / typecheck / lint / build / test across root, ui/cli,
ui/web); circular-deps baseline preserved.

* refactor(auth): rename ConfigValidator to AuthConfigValidator for name coherence

The file src/charging-station/ocpp/auth/utils/ConfigValidator.ts exports a
single object under the name AuthConfigValidator and uses the string
'AuthConfigValidator' as its moduleName for log prefixes, but the file itself
was named after a more generic 'ConfigValidator' concept. Two consumers and
the test file already refer to the symbol as AuthConfigValidator, and the
generic file name overlaps semantically with the unrelated
src/utils/ConfigurationValidation.ts (application config validation) —
grep 'ConfigValidator' surfaces both, which is easy to misread.

Rename via 'git mv' to preserve history:
- src/charging-station/ocpp/auth/utils/ConfigValidator.ts
  -> src/charging-station/ocpp/auth/utils/AuthConfigValidator.ts
- tests/charging-station/ocpp/auth/utils/ConfigValidator.test.ts
  -> tests/charging-station/ocpp/auth/utils/AuthConfigValidator.test.ts

Update imports in AuthComponentFactory and OCPPAuthServiceImpl to reference
the new file path. No source content changes; exported symbol name and
module identity are unchanged.

Skipped audit items in this tier:
- z-A1-F3 (OCPP_WEBSOCKET_TIMEOUT_MS -> global Constants): declined. The
  constant already lives in src/charging-station/ocpp/OCPPConstants.ts, which
  is the OCPP-domain constants class. Promoting it to the charging-station-
  wide Constants would blur module boundaries with no consumer benefit —
  consistent with the same rationale that deferred UIServerSecurity DEFAULT_*
  centralization.
- z-A1-F4 (rename DEFAULT_ATG_WAIT_TIME_MS -> DEFAULT_ATG_RETRY_DELAY_MS):
  declined. All three call sites in AutomaticTransactionGenerator
  (waitChargingStationAvailable, waitConnectorAvailable,
  waitRunningTransactionStopped) are sleep() delays inside 'wait for
  condition' polling while-loops, not retry loops on a failing operation.
  'RETRY_DELAY' would misdescribe the semantics; the current 'WAIT_TIME' is
  the accurate label.

All gates pass (format / typecheck / lint / build / test).

* test: use TEST_SUPERVISION_URL constant instead of hardcoded ws://localhost:8080

Six test files repeated the literal 'ws://localhost:8080' 15 times as a
supervision URL fixture. tests/utils/TestNetworkConstants.ts already provides
the canonical TEST_SUPERVISION_URL:

  export const TEST_SUPERVISION_URL =
    `ws://${Constants.DEFAULT_UI_SERVER_HOST}:${Constants.DEFAULT_UI_SERVER_PORT.toString()}`

Route each fixture through that constant so a change to the production UI
server host/port defaults propagates to every test without a search/replace
sweep. Files updated:

- tests/charging-station/TemplateValidation.test.ts (6 sites)
- tests/charging-station/TemplateMigrations.test.ts (3 sites)
- tests/charging-station/TemplateSchema.test.ts     (1 site)
- tests/utils/ConfigurationSchema.test.ts           (3 sites — including the
  first entry of the ['ws://localhost:8080', 'ws://localhost:8081'] array;
  the second literal is intentionally distinct and stays hardcoded)
- tests/utils/ConfigurationValidation.test.ts       (1 site)
- tests/performance/storage/MikroOrmStorage.test.ts (1 site)

The JSDoc example in tests/charging-station/mocks/MockWebSocket.ts is
preserved as-is (documentation, not a fixture).

Fixture values, test assertions, and behavior are unchanged.
All gates pass (format / typecheck / lint / build / test).

* refactor: reword narrative comments and anchor non-systemic eslint-disable directives

Two audit tiers bundled to share the gate run.

Tier 8 - narrative/imperative comments -> state-describing form:
Three comments in touched files were rewritten from developer-narrative to
state-describing per the repo comment convention.

- src/charging-station/ocpp/2.0/OCPP20CertificateManager.ts
  'We need the directory: basePath/ChargingStationCertificate' is replaced
  with a two-line state description of what dirPath is and how it relates
  to the getCertificatePath return value. Anchors the non-obvious
  resolve(certFilePath, '..') derivation to the surrounding directory check.
- src/charging-station/ocpp/auth/strategies/CertificateAuthStrategy.ts
  'Should not reach here due to canHandle check, but handle gracefully'
  becomes 'Unreachable when the canHandle contract holds; defensive fallback
  for unsupported OCPP versions'.
  'Must have certificate data in the identifier' becomes
  'The identifier carries certificate data' (paired with a matching rewrite
  of 'Certificate authentication must be enabled' -> '...is enabled per
  configuration').

Tier 9 - '-- reason' anchors on non-systemic eslint-disable directives:
Sixteen one-off eslint-disable-next-line directives now carry the '-- reason'
anchor already used elsewhere in the repo (e.g. UIServerFactory.ts,
OCPPResponseService.ts). Systemic directives (repeated idioms such as
@typescript-eslint/no-redeclare for the enum + type declaration-merging
pattern, restrict-template-expressions for logger calls, no-extraneous-class
for the Constants classes, no-unnecessary-type-parameters for contravariant
handler bridges) are left unchanged in this pass — those are pattern-level
choices that belong in a repo-wide rationale, not per-site.

Anchored one-offs:
- src/types/ocpp/ErrorType.ts @cspell/spellchecker
- src/charging-station/ChargingStation.ts @typescript-eslint/no-deprecated
- src/charging-station/ChargingStation.ts promise/no-promise-in-callback
- src/charging-station/broadcast-channel/ChargingStationWorkerBroadcastChannel.ts
    three @typescript-eslint/no-invalid-void-type + one promise/catch-or-return
- src/charging-station/TemplateMigrations.ts @typescript-eslint/no-dynamic-delete
- src/charging-station/ocpp/1.6/OCPP16IncomingRequestService.ts no-fallthrough
- src/charging-station/ocpp/OCPPServiceUtils.ts @typescript-eslint/no-unsafe-assignment
- src/charging-station/ocpp/OCPPRequestService.ts @typescript-eslint/no-this-alias
- src/charging-station/Bootstrap.ts two @typescript-eslint/unbound-method
- src/worker/WorkerSet.ts promise/no-promise-in-callback
- ui/web/src/shared/composables/useTheme.ts no-void
- ui/web/src/shared/composables/useAsyncAction.ts no-void

Behavior is unchanged; every directive still disables the same rule at the
same line, only the rationale is now recorded inline.

Gates green (format / typecheck / lint / build / test) across root and ui/web;
circular-deps baseline of 62 preserved.

* refactor(utils): extract FieldError into a single-purpose module

Two review findings from PR #1959 addressed together — both are internal
tidying with no behavior change.

1. Extract Zod field-error helpers out of ConfigurationMigrations
   ConfigurationMigrations owned the FieldError interface plus
   mapZodIssuesToFieldErrors and formatFieldErrorsSummary, but those helpers
   are Zod-issue formatters used by TemplateValidation and
   ConfigurationValidation — neither of which is a configuration-migration
   concern. Move the interface and both helpers into a dedicated single-
   purpose module src/utils/FieldError.ts, re-export via the utils barrel,
   and route all three consumers to import from ./FieldError.js:
   - src/utils/ConfigurationMigrations.ts now imports the FieldError type
     it still uses in remapDeprecatedKeys and RemapDeprecatedKeysResult.
   - src/utils/ConfigurationValidation.ts imports the type plus both helpers.
   - src/charging-station/TemplateValidation.ts switches from the deep
     import of ConfigurationMigrations to the deep import of FieldError.
   The utils/index.ts barrel gains a FieldError export block placed
   alphabetically between ErrorUtils and FileUtils.

2. Add TEST_SUPERVISION_URL_ALT_PORT for two-URL fixtures
   tests/utils/ConfigurationSchema.test.ts had a mixed literal/constant
   array — [TEST_SUPERVISION_URL, 'ws://localhost:8081'] — used to verify
   supervisionUrls accepts a string array. Both entries now come from
   TestNetworkConstants: the alternate port is DEFAULT_UI_SERVER_PORT + 1,
   keeping the same 'derived from production defaults' contract. The
   TestNetworkConstants file also factors HOST/PORT into local constants
   to keep the two URL definitions DRY.

Additional review-finding dispositions:

- Finding #3 (visual QA on the DEFAULT_SKIN fallback change): already
  regression-locked by existing Vitest coverage —
  tests/unit/skins/registry.test.ts:12 pins DEFAULT_SKIN === 'modern',
  tests/unit/shared/composables/useSkin.test.ts exercises
  switchSkin(DEFAULT_SKIN) and asserts activeSkinId defaults to
  DEFAULT_SKIN. No additional test needed.
- Finding #4 (Validation vs Validator terminology): withdrawn on
  re-inspection. *Validation.ts modules host the validation process
  (validateConfiguration, ConfigurationValidationError). AuthConfigValidator
  is a validator entity object with a .validate() method. The suffix
  distinguishes 'process module' from 'entity object' — intentional and
  consistent, not divergent.
- Finding #5 (spec coverage for VariableCharacteristics.valueList and
  EventData.actualValue): pre-existing feature-not-implemented gaps in the
  simulator (grep confirms neither valueList nor NotifyEvent code paths
  exist under src/charging-station/ocpp/2.0/). Bounding these would
  require implementing the underlying features first; out of scope for
  this review-response commit.

Public API surface unchanged. Gates green (format / typecheck / lint /
build / test); circular-deps baseline of 62 preserved.

* chore(charging-station): unify TemplateValidation utils imports through the barrel

Round-2 review finding: TemplateValidation.ts mixed two import styles for
the same target directory. Lines 6-10 pulled FieldError + helpers via a
deep import ('../utils/FieldError.js') while line 11 pulled other utilities
via the barrel ('../utils/index.js'). Both symbol groups originate from
src/utils/ and the barrel already re-exports the FieldError trio (utils/
index.ts:43-47).

Merge both into a single barrel import block, alphabetically ordered
(case-insensitive) per the repo's perfectionist convention:
  assertIsJsonObject, clone, type FieldError, formatFieldErrorsSummary,
  isEmpty, isNotEmptyString, logger, mapZodIssuesToFieldErrors.

The 'type FieldError' inline qualifier is kept in the mixed named-import
(same pattern used by utils/index.ts's own FieldError re-export block).
Behavior unchanged; only import mechanics shift.

All gates pass (format / typecheck / lint / build / test); skott circular-
deps baseline of 62 preserved.

3 weeks agochore(deps): update all non-major dependencies (#1957)
renovate[bot] [Mon, 6 Jul 2026 12:13:01 +0000 (14:13 +0200)] 
chore(deps): update all non-major dependencies (#1957)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
3 weeks agorefactor: convention alignment round 2 — canonical defaults, helpers, dead exports...
Jérôme Benoit [Mon, 6 Jul 2026 11:52:59 +0000 (13:52 +0200)] 
refactor: convention alignment round 2 — canonical defaults, helpers, dead exports, tests (#1956)

* refactor: convention alignment round 2 — canonical defaults, helpers, dead exports, tests

Follow-up to #1950/#1952/#1953/#1954. Post-audit factorization; behavior-preserving.

Canonical defaults promoted to Constants.ts:
- UNIT_DIVIDER_CENTI/DECI (100/10); adopt UNIT_DIVIDER_KILO at 3 kW/kWh sites
- DEFAULT_RECONNECT_JITTER_PERCENT, SOC_MAXIMUM_PERCENT
- DEFAULT_AUTH_CACHE_MAX_ABSOLUTE_LIFETIME_MS
- DEFAULT_REMOTE_AUTH_CACHE_TTL_SECONDS
- MS_PER_DAY, SECONDS_PER_DAY (align with existing MS_PER_HOUR)

Canonical defaults promoted to OCPP20Constants.ts (OCPP-variable-read fallbacks):
- DEFAULT_RETRY_BACKOFF_{WAIT_MINIMUM,RANDOM_RANGE}_SECONDS
- DEFAULT_RETRY_BACKOFF_REPEAT_TIMES
- DEFAULT_CERT_SIGNING_WAIT_MINIMUM_SECONDS

Module-scope constants:
- WS_DEFLATE_{CONCURRENCY_LIMIT,SERVER_MAX_WINDOW_BITS,ZLIB_*} in UIWebSocketServer
- STATISTICS_PERCENTILE in PerformanceStatistics
- AUTH_CACHE_LIFETIME_{MIN,MAX}_SECONDS, AUTH_CACHE_MIN_ENTRIES,
  AUTH_TIMEOUT_{MIN,MAX}_SECONDS in ConfigValidator
- JITTER_PERCENT local to src/worker/ (module standalone; no cross-dep)
- SIGINT/SIGTERM_EXIT_CODE, MISSING_VALUE_PLACEHOLDER,
  TEMPLATE_NAME_SUFFIX, TRUNCATED_HASH_ID_LENGTH in ui/cli output
- SKIN_ERROR_RELOAD_COUNT_KEY in ui/web core

Helper adoption:
- millisecondsToSeconds (date-fns) at AbstractUIServer / InMemoryAuthCache / test
- isNotEmptyString in TemplateValidation
- WebSocketReadyState enum (ui-common) at CLI wsIcon/renderers and web stationStatus
- OCPP16ChargePointStatus / OCPP20ConnectorStatusEnumType enum keys instead
  of string literals in CLI STATUS_ABBREVIATIONS and web CONNECTOR_STATUS_VARIANT
- nonEmptyStringOrUndefined shared helper (ui/web/src/shared/utils) adopted
  by 3 composables + skin error extractor
- Extracted OCPPRequestService.logRequestHandlerError() consumed by OCPP16/20
  RequestService (byte-identical catch shell)

Barrel/module hygiene:
- Close barrel gap: meter-values/index.ts exposes disposeCoherentSessionRuntime
  (JSDoc already advertised it)
- UIServerAccessPolicy imports UI_SERVER_ACCESS_POLICY_DEFAULTS via utils/index
  barrel (drop deep import)
- ui/web skins route 8 imports through @/shared/utils/index barrel
- UIServiceWorkerBroadcastChannel type-only imports AbstractUIService via
  ui-server/index barrel (safe by type erasure; comment documents the constraint)

Dead barrel exports pruned (0 external consumers):
- src/utils: FieldError, applyConfigurationMigration, coerceConfigurationVersion,
  queueMicrotaskErrorThrowing (tests updated to import from source)
- charging-station/meter-values: ChargingCurvePoint
- ui-server/mcp: MCPToolSchema
- ocpp/auth: AuthStats, CacheStats, CertificateAuthProvider, CertificateInfo
- ocpp/2.0/__testable__: SendMessageFn, TestableRequestServiceOptions/Result
  re-exports; TestableOCPP20IncomingRequestService downgraded to module-private;
  TestableOCPP20VariableManager type re-export dropped

Anchor-comment reasons added on 3 eslint-disable directives (Utils /
StatisticUtils / UIServerFactory).

Test constants:
- Heartbeat interval sites use Constants.DEFAULT_HEARTBEAT_INTERVAL_MS /
  secondsToMilliseconds
- New tests/utils/TestNetworkConstants.ts: TEST_SUPERVISION_URL derived from
  Constants.DEFAULT_UI_SERVER_{HOST,PORT}; adopted at MessageChannelUtils.test
  + StorageTestHelpers + ConfigurationFixtures
- Rate-limit / mock-timers-tick 60000 literals replaced by minutesToMilliseconds(1)
  in UIServerSecurity / OCPP20CertSigningRetryManager / SignedMeterValues tests

Deliberately not applied (documented rationale):
- src/worker/WorkerAbstract isNotEmptyString adoption: src/worker/ is a
  standalone module with zero cross-module dependencies; retracted rather
  than adding an import to ../utils
- AbstractUIService → broadcast-channel/index value-import via barrel:
  empirically amplifies circular dependencies 62 → 71 because the barrel also
  re-exports ChargingStationWorkerBroadcastChannel whose transitive graph
  closes a runtime cycle back through ui-server/index; deep import kept

Quality gates:
- pnpm typecheck: clean (root + ui-common + cli + web)
- pnpm lint: 0 errors (1 pre-existing cspell warning unchanged)
- pnpm circular-deps: 62 (baseline unchanged)
- pnpm test: 2955/2961 pass (6 skipped pre-existing; 0 fail)

* [autofix.ci] apply automated fixes

* refactor: address self-review findings — DRY constant refs, helper adoption, naming

Fixes 9 findings surfaced by fan-out self-review of commit a25f4ba9.
Behavior-preserving; no public API change.

Findings addressed:

- H1 (DRY, Constants.ts) — `DEFAULT_AUTH_CACHE_MAX_ABSOLUTE_LIFETIME_MS` and
  `MS_PER_DAY` both held the literal `86_400_000` (same value, same commit).
  Class-static forward-reference is illegal in TypeScript (TS2729:
  "Property 'B' is used before its initialization"), so the shared literal is
  hoisted to module-scope `DAY_IN_MS` / `DAY_IN_SECONDS` helpers before the
  class and referenced from both derived defaults and canonical time-unit
  constants. Single source of truth restored.

- H2 (DRY, ConfigValidator.ts) — `AUTH_CACHE_LIFETIME_MAX_SECONDS = 86_400`
  duplicated `Constants.SECONDS_PER_DAY = 86_400`. Fixed cross-file by
  importing `Constants` (already reachable via `../../../../utils/index.js`)
  and referencing `Constants.SECONDS_PER_DAY`.

- M1 (helper adoption, ui/cli format.ts:102) — `fuzzyTime` still used
  `diff / 1000` while the same PR adopted `millisecondsToSeconds` from
  `date-fns` at 4 other sites. Replaced by `millisecondsToSeconds(diff)`
  for consistency.

- L1 (naming coherence, ConfigValidator.ts) — Renamed
  `AUTH_CACHE_LIFETIME_{MIN,MAX}_SECONDS` to
  `AUTH_CACHE_TTL_{MIN,MAX}_SECONDS` to align with the pre-existing
  `DEFAULT_AUTH_CACHE_TTL_SECONDS` naming family. Two words for one concept
  eliminated within the auth module.

- L2 (naming coherence, ui/cli output) — Renamed cli
  `MISSING_VALUE_PLACEHOLDER` to `EMPTY_VALUE_PLACEHOLDER` to align with
  ui/web's pre-existing `EMPTY_VALUE_PLACEHOLDER`. Values remain
  medium-specific (`'–'` cli / `'Ø'` web), only the semantic role name is
  shared. 6 sites updated.

- L3 (stranded export, format.ts) — `TRUNCATED_HASH_ID_LENGTH` had zero
  external consumers (only the in-file default arg on `truncateId`).
  Dropped `export` keyword.

- L4 (stranded export, format.ts) — `TEMPLATE_NAME_SUFFIX` had zero external
  consumers (only the in-file `stripTemplateSuffix` helper). Dropped
  `export` keyword; the helper itself keeps `export` (2 external consumers).

- L5 (documented duplication, WorkerUtils.ts) — JSDoc on `JITTER_PERCENT`
  now explicitly cross-references `Constants.DEFAULT_RECONNECT_JITTER_PERCENT`
  and documents the maintenance invariant. The two constants must stay
  synchronized manually because `src/worker/` is a standalone module (no
  cross-module value imports).

- I1 (anchor accuracy, StatisticUtils.ts:52) — The
  `no-unnecessary-condition` disable reason is reworded from a plausible-
  but-imprecise "JSON.parse violation" framing to the actual root cause:
  `baseIndex+1` can equal `sortedDataSet.length`, and TypeScript indexes
  as `number` (no `noUncheckedIndexedAccess`), so the runtime `!= null`
  guard is genuinely needed for out-of-bounds protection.

Empirical retraction verifications:

- B1-5 (cycle amplification 62 → 71) — Re-verified by applying only the
  retracted change on top of `daad8a9d` and running `pnpm circular-deps`:
  62 baseline, 71 after B1-5, 62 after revert. Retraction rationale holds.

- A1-17 (`src/worker/` standalone) — Re-verified by `grep -rE
  "^import.*\.\./" src/worker/`: zero cross-module value imports; the new
  L5 JSDoc documents the exception rationale in-source.

Quality gates:
- pnpm typecheck: clean (root + ui-common + cli + web)
- pnpm lint: 0 errors (1 pre-existing cspell warning unchanged)
- pnpm circular-deps: 62 (baseline unchanged)
- pnpm test: 2955/2961 pass (6 skipped pre-existing; 0 fail)

* docs: fix JSDoc accuracy from review v3 findings (G1, A1, A2)

Doc-only follow-up to commit 6ba8503c. Behavior unchanged.

- G1 (JITTER doc accuracy) — Reworded both `Constants.DEFAULT_RECONNECT_JITTER_PERCENT`
  and worker-local `JITTER_PERCENT` JSDoc from the empirically false claim
  "symmetric ±20 %" to "bounds the jitter within ±20 %". The `randomizeDelay`
  algorithm couples sign and magnitude through a single uniform draw, so with
  `random ∈ [0, 1)` and `sign = random < 0.5 ? -1 : +1` the output range is
  `(0.9·delay, delay] ∪ [1.1·delay, 1.2·delay)` — asymmetric, with a gap at
  `[delay, 1.1·delay)`. The new wording accurately caps the magnitude without
  overclaiming symmetry; the WorkerUtils docstring adds an explicit distribution
  note pointing to `randomizeDelay`. Algorithm itself pre-dates the PR and is
  behavior-preserved.

- A1 (InMemoryAuthCache @param defaults) — Three JSDoc `@param default:` fields
  were pointing at raw literals (3600, 86400000, 60000) instead of the promoted
  Constants members. Aligned with the pattern already used for `maxEntries`
  (which cites `Constants.DEFAULT_AUTH_CACHE_MAX_ENTRIES`): now all six
  `@param default:` values reference the canonical `Constants.*` symbol.

- A2 (DEFAULT_REMOTE_AUTH_CACHE_TTL_SECONDS wording) — JSDoc used the phrase
  "distinct from local `_TTL_SECONDS = 3600`" where `_TTL_SECONDS` is not an
  actual symbol. Replaced by explicit sibling reference
  `DEFAULT_AUTH_CACHE_TTL_SECONDS (3600, local cache default)`.

Quality gates:
- pnpm typecheck: clean (root + ui-common + cli + web)
- pnpm lint: 0 errors (1 pre-existing cspell warning unchanged)
- pnpm circular-deps: 62 (baseline unchanged)

* fix: web-test regression from A2-5 + review v4 findings + documented quality gates

Restores web test suite to full green after documented quality gates surfaced
regressions the prior review rounds missed. Behavior-preserving vs the intent
of round-1 refactor.

Regressions fixed (QG-1/QG-2/QG-3 — blockers surfaced by `pnpm --filter web
test:coverage`):

- `stationStatus.ts` — round-1 finding A2-5 replaced `switch (status?.toLowerCase())`
  by a Record keyed by `OCPP16ChargePointStatus.*` / `OCPP20ConnectorStatusEnumType.*`
  enum values. That change (a) dropped the case-insensitive contract asserted by
  `stationStatus.test.ts:74`, and (b) introduced a module-scope runtime access
  to `ui-common` enum values, which fails at module init when the containing
  test suite mocks `ui-common` (2 file-level FAILs in `useAddStationsForm.test.ts`
  and `useStartTxForm.test.ts`).

  Design chosen (Option F in review v4 report): keep the Record structure but
  key it by lowercase string literals and normalize input via `.toLowerCase()`
  on lookup. Retains the refactor benefit (Record > switch for O(1) lookup,
  clearer add/remove semantics), restores case-insensitive input, removes the
  module-scope enum dependency, and preserves behaviour for the 9 status values
  the switch covered. Enum imports dropped since no other value site remains.

  Web test suite: 31 files / 532 tests pass (previously 3 files failed / 1
  test failed).

Review v4 low-severity findings:

- v4-A1 `Constants.ts:77` — JSDoc "bounds within ±20 %" for
  `DEFAULT_RECONNECT_JITTER_PERCENT` implied a bidirectional range, but algebra
  of the sole consumer `computeExponentialBackOffDelay` (`jitter = delay ×
  jitterPercent × secureRandom()` with `secureRandom() ∈ [0, 1)`) yields
  `jitter ∈ [0, +20 %)` strictly non-negative. Reworded to describe the
  additive-uniform-draw semantic and cross-reference the two distinct consumer
  distributions.
- v4-A2 `WorkerUtils.ts:12` — "shared jitter policy" overclaimed behavioural
  parity between the two consumers (positive-only vs asymmetric with a
  probability-zero gap). Softened to "shared jitter magnitude cap".
- v4-B1 `ConfigurationFixtures.ts:67` — `uiServer.options` still hardcoded
  `host: 'localhost', port: 8080` after the sibling `supervisionUrls` field on
  the same object literal was migrated to `TEST_SUPERVISION_URL`. Extended
  `tests/utils/TestNetworkConstants.ts` to also export `TEST_UI_SERVER_HOST`
  and `TEST_UI_SERVER_PORT` (re-exports of `Constants.DEFAULT_UI_SERVER_HOST` /
  `Constants.DEFAULT_UI_SERVER_PORT`).
- v4-B4 `ConfigurationMigrations.test.ts:130,141` — string literals
  `'ws://localhost:8080'` in a file the PR already touched (barrel-to-source
  import migration) but where the URL literals were skipped in round 2.
  Migrated to `TEST_SUPERVISION_URL`.

Lint policy alignment (user directive "aucun warning"):

- `cspell.config.yaml` — added `subclassing` (used in
  `src/charging-station/ui-server/index.ts:10`, pre-existing since #1954). Root
  lint now reports 0 errors AND 0 warnings.

Deferred (documented in review v4 report, out of this PR's scope):

- v4-B2 `ConfigValidator.ts` MIN/MAX bounds harmonization (already tracked as M2)
- v4-B3 asymmetry between `1.6/__testable__/index.ts` (exports
  `TestableOCPP16IncomingRequestService`) and `2.0/__testable__/index.ts`
  (interface demoted to non-exported in round 2). Requires either restoring
  the 2.0 export or migrating 1.6 consumers to `ReturnType<typeof …>` —
  larger touch, kept for a dedicated follow-up.
- Pre-existing gap/asymmetry in `randomizeDelay`'s distribution
  (`(−10 %, 0] ∪ [+10 %, +20 %)` — magnitude and sign both derive from a single
  uniform draw). Documented in-source, not fixed by this refactor.

Quality gates (documented per `.serena/memories/task_completion_checklist`):

- Root: `pnpm format` / `typecheck` / `lint` (0 err, 0 warn) / `build` / `test`
  (2955 pass / 6 skipped / 0 fail)
- CLI: `pnpm format` / `typecheck` / `lint` / `build` / `test` (152/152 pass)
- Web: `pnpm format` / `typecheck` / `lint` / `build` / `test:coverage`
  (532/532 pass, 31 test files)
- Circular deps: 62 (baseline unchanged)

* docs: tighten prose in 5 comments — state-describing, not narrative

Removes historical/imperative framing ("kept as", "hoisted to", "if a future
edit converts...", "must not access... because") in favour of concise
statements of what is.

- `Constants.ts:9` module-scope helper: 3→2 lines
- `Constants.ts:80` DEFAULT_RECONNECT_JITTER_PERCENT JSDoc: 6→4 lines
- `WorkerUtils.ts:1` JITTER_PERCENT JSDoc: 9→5 lines
- `UIServiceWorkerBroadcastChannel.ts:1` type-only barrier: 5→3 lines
- `stationStatus.ts:64` CONNECTOR_STATUS_VARIANT: reworded, same length

Net: -9 lines. All invariants preserved (TS2729, cross-module boundary,
runtime-cycle prevention, no-module-scope-enum). Behavior unchanged.

Quality gates:
- Root: format / typecheck / lint (0 err, 0 warn) / build / test (2955 pass)
- CLI: 5/5 clean (152/152 pass)
- Web: 5/5 clean (532/532 pass, test:coverage)

* fix: address review v5 findings — imperative comment, dead exports, module-scope enum

Applies the 3 actionable findings from review round 5. Behavior-preserving.

- v5-A1 `SkinLoadError.vue:28-32` — Reworded `resetToDefault` JSDoc from 4-line
  imperative `NOTE: ...should clear the reload-count sessionStorage key...`
  to 1-line state-describing `Counter is reset by useSkin.switchSkin on
  successful load.` Missed by the round-4 comment tightening sweep.

- v5-B1 `TestNetworkConstants.ts` / `ConfigurationFixtures.ts` — Retired
  `TEST_UI_SERVER_HOST` / `TEST_UI_SERVER_PORT` exports (1 external consumer
  each, both at the same `ConfigurationFixtures.ts:71` expression — criterion 10
  requires ≥2 consumers). Inlined `Constants.DEFAULT_UI_SERVER_HOST` /
  `Constants.DEFAULT_UI_SERVER_PORT` at the single call site.
  `TEST_SUPERVISION_URL` remains exported (5 consumers).

- v5-B2 `ui/web/src/shared/utils/stationStatus.ts` — Removed the module-scope
  value import `import { WebSocketReadyState } from 'ui-common'` and switched
  `getWebSocketStateVariant` to 4 module-local numeric constants
  `WS_STATE_{CONNECTING,OPEN,CLOSING,CLOSED} = 0/1/2/3`. Eliminates the same
  ui-common runtime module-scope dependency that the round-4 fix removed for
  `CONNECTOR_STATUS_VARIANT`. No behavior change (values match WHATWG WebSocket
  readyState spec).

Quality gates:
- Root: 5/5 clean, 2942/2948 pass
- CLI: 5/5 clean, 152/152 pass
- Web: 5/5 clean, 532/532 pass (test:coverage)
- Circular deps: 62 (baseline)

* docs: drop narrative comments in stationStatus.ts

Removes two comment blocks that narrated rationale ("mirror of...",
"module-local to keep...", "test suites mock...") instead of describing
what is. The `WS_STATE_*` constants and lowercase Record keys are
self-documenting.

- `stationStatus.ts:62-66` — 4-line rationale block removed; `cspell:ignore`
  directive retained
- `stationStatus.ts:83-85` — 3-line rationale block removed

Quality gates: root/web 5/5 clean, 532/532 pass.

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
3 weeks agochore: harmonize test filenames to PascalCase concern suffix convention (#1955)
Jérôme Benoit [Sun, 5 Jul 2026 21:30:16 +0000 (23:30 +0200)] 
chore: harmonize test filenames to PascalCase concern suffix convention (#1955)

chore: harmonize test filenames to PascalCase concern suffix convention

Rename 9 test files whose naming violated the codebase's dominant
`<Source>[-<PascalConcern>].test.ts` convention. 156/165 test files were
already conformant; this PR closes the remaining gap.

Renames (all via `git mv`, content unchanged):

Casing/separator normalizations (7):
- tests/utils/Configuration-hot-reload.test.ts
  → tests/utils/Configuration-HotReload.test.ts (kebab-case lowercase)
- tests/utils/ConfigurationValidation-perf.test.ts
  → tests/utils/ConfigurationValidation-Perf.test.ts (lowercase)
- tests/charging-station/ocpp/OCPPServiceUtils-pure.test.ts
  → tests/charging-station/ocpp/OCPPServiceUtils-Pure.test.ts (lowercase)
- tests/charging-station/ocpp/OCPPServiceUtils-meterValues.test.ts
  → tests/charging-station/ocpp/OCPPServiceUtils-MeterValues.test.ts (camelCase)
- tests/charging-station/ocpp/OCPPServiceUtils-validation.test.ts
  → tests/charging-station/ocpp/OCPPServiceUtils-Validation.test.ts (lowercase)
- tests/charging-station/ocpp/2.0/OCPP20ServiceUtils-enforceMessageLimits.test.ts
  → tests/charging-station/ocpp/2.0/OCPP20ServiceUtils-EnforceMessageLimits.test.ts
  (camelCase)
- tests/charging-station/ui-server/UIMCPServer.integration.test.ts
  → tests/charging-station/ui-server/UIMCPServer-Integration.test.ts
  (`.integration.` — sole `.` separator in the test tree; now aligned on
  the `-Integration` pattern used by 5 sibling integration tests)

Aggregate-pattern alignment (1):
- tests/charging-station/ocpp/1.6/OCPP16-CoherentMeterValues.test.ts
  → tests/charging-station/ocpp/1.6/OCPP16Integration-CoherentMeterValues.test.ts
  Sole use of `OCPP16-<Concept>` (version-dash without Integration) in
  the entire test tree. The file's own `@file` header describes itself
  as an "Integration-style test", aligning it with the established
  `OCPP16Integration-<PascalConcern>` aggregate pattern used by 4
  siblings (`-ChargingProfiles`, `-Configuration`, `-Reservations`,
  `-Transactions`).

OCPP standard casing (1):
- tests/charging-station/ocpp/2.0/OCPP20RequestService-HeartBeat.test.ts
  → tests/charging-station/ocpp/2.0/OCPP20RequestService-Heartbeat.test.ts
  Every other file in the 12-file `OCPP20RequestService-*` group uses
  the OCPP standard action spelling (`BootNotification`, `MeterValues`,
  `NotifyReport`, etc.). OCPP 2.0.1 spells the action `Heartbeat` (single
  word); the `HeartBeat` variant with a mid-word capital B was the sole
  outlier.

Not renamed (accepted as legitimate cases):
- OCPPAuthIntegration.test.ts — "Integration" fused into the concept
  name, no per-concern split.
- OCPP20Integration.test.ts — aggregate test with no per-concern split.
- CoherentMeterValues.test.ts (meter-values/) — PascalCase concept name
  for a multi-file subsystem, no casing violation.
- StrategyDispatch.test.ts, UIMetricsEndpoint.test.ts — PascalCase
  concept/aggregate names, no casing violation.

Content preserved verbatim in 8 of the 9 renamed files (case-only and
separator renames via `git mv` two-step for macOS APFS
case-insensitivity). The 9th file (`OCPP20RequestService-Heartbeat.test.ts`)
had 8 internal `HeartBeat` references (JSDoc `@file`, 5 `it()` descriptions,
2 inline comments) updated to `Heartbeat` to match the OCPP 2.0.1 spec
spelling — the same rationale that motivated the filename rename. Source
code already uses `Heartbeat` consistently (`src/types/ocpp/2.0/Requests.ts:
HEARTBEAT = 'Heartbeat'`).

No consumer updates required: the only non-test references to the old
filenames live in auto-regenerated caches (`.serena/cache`,
`.hermes/evidence`, `.eslintcache`) which self-heal, and historical
planning docs under `.omo/plans/` (frozen; not updated).

Post-PR: 165/165 test files conformant to the codebase's dominant
`<Source>[-<PascalConcern>].test.ts` / `<Concept>Integration[-<PascalConcern>].test.ts`
conventions.

Quality gates: typecheck clean; the 9 renamed test files run
80/80 pass, 0 fail.

Out of scope for this PR (documented for future work):
- ui/web/tests/ has 7 outliers (Utils/Dialogs/Actions casing mismatch vs
  source dirs; Errors casing vs source file; SimpleComponents /
  ClassicComponents / SkinComponents aggregate names with no matching
  source entity). These live in a different workspace with its own
  convention (source-mirror casing per file role); a dedicated
  ui/web-scoped harmonization PR is warranted.
- Helper file placement inconsistency (some in `helpers/`, some
  co-located with `.test.ts` files) and 5 competing suffixes
  (TestUtils/TestHelpers/TestConstants/TestData/Fixtures) — needs a
  documented convention in TEST_STYLE_GUIDE before rename PR.
- `mocks/` vs `auth/helpers/` split for mocks (MockCaches/MockWebSocket
  in mocks/, MockFactories in auth/helpers/) — same follow-up.

3 weeks agorefactor: complete barrel-gap scope — extract host-parsing utilities and expose Abstr...
Jérôme Benoit [Sun, 5 Jul 2026 20:21:38 +0000 (22:21 +0200)] 
refactor: complete barrel-gap scope — extract host-parsing utilities and expose AbstractUIServer (#1954)

refactor: complete barrel-gap scope — extract host-parsing utilities and expose AbstractUIServer

Complete the scope that #1952 and #1953 left incomplete. Three coupled
concerns addressed atomically to avoid further PR fragmentation:

R1. Extract host-parsing utilities from ui-server internals to
    src/utils/HostUtils.ts:

    The `ui-server/UIServerNet.ts` file previously mixed pure host/IP
    parsing helpers (`LOOPBACK_HOSTNAME`, `isLoopback`,
    `normalizeIPAddress`, `normalizeHost`, `isHostLiteralWithoutPort`)
    with HTTP-header parsing helpers (`splitHeaderList`, `splitQuoted`).
    The host-parsing family is a pure utility, not UI-server-specific,
    and was consumed cross-layer by `src/utils/ConfigurationSchema.ts`
    (a Zod validator) — an inverted dependency (`utils/` importing from
    `charging-station/ui-server/`) that required a workaround comment
    documenting the TDZ (Temporal Dead Zone) cycle risk at
    UIServerNet.ts:3.

    Move the 5 host-parsing exports plus their private helpers
    (`HOSTNAME_PATTERN`, `isValidPort`, `parseIPv4MappedAddress`,
    `expandIPv6`, `isIPv6Group`) to `src/utils/HostUtils.ts`. The
    `ui-server/UIServerNet.ts` file now contains only the HTTP-header
    parsing helpers (`splitHeaderList`, `splitQuoted`) that legitimately
    belong to the ui-server access-policy layer.

    Callers updated:
    - ChargingStation ui-server internals (`AbstractUIServer.ts`,
      `UIServerAccessPolicy.ts`, `UIServerFactory.ts`) now import from
      `../../utils/index.js` (the utils barrel), each in a single
      alphabetically-sorted specifier block merged with the pre-existing
      utils imports from that file (no duplicate import statements).
    - `src/utils/ConfigurationSchema.ts` imports from `./HostUtils.js`
      directly (same directory, avoids barrel TDZ risk).
    - The utils barrel (`src/utils/index.ts`) re-exports the 5 host
      helpers alphabetically.

    Priority-3 anchor comments preserved verbatim in the moved private
    helpers (radix-16 rationale for `Number.parseInt` in
    `parseIPv4MappedAddress` and `expandIPv6`).

R2. Expose AbstractUIServer as a type-only re-export from the
    ui-server barrel:

    Bootstrap.ts holds a `readonly uiServer: AbstractUIServer` field
    without subclassing the abstract base — a legitimate external
    type-reference consumer. Previously deep-imported via
    `./ui-server/AbstractUIServer.js`. Now available through the
    barrel via `export type { AbstractUIServer } from './AbstractUIServer.js'`.
    Bootstrap merges its two ui-server imports into a single barrel
    import.

    The ui-server barrel `@file` header updates:
    - Move `AbstractUIServer` from the "Deliberately not re-exported"
      list to a positive mention: "re-exported as a type-only symbol
      for external consumers (Bootstrap) that hold a reference to the
      selected concrete server without subclassing it".
    - Retitle the `UIServerNet` bullet: the host-parsing helpers no
      longer live here; the barrel now cross-references
      `src/utils/HostUtils.ts` so a reader chasing them does not
      wonder where they went.
    - Complete the "Deliberately not re-exported" enumeration to also
      name the omitted internal symbols from `UIServerSecurity`
      (`DEFAULT_MAX_PAYLOAD_SIZE_BYTES`, `PayloadTooLargeError`,
      `readLimitedBody`) and `UIServerUtils`
      (`isProtocolAndVersionSupported`) so the list is exhaustive.

R3. Adjacent-sub-component cycle already resolved in #1953: the
    `broadcast-channel ↔ ui-server/ui-services` value-import back-edge
    remains a direct import (not via the barrel) per the codebase's
    established convention (`meter-values/index.ts`: "Internal helpers
    are intentionally not re-exported; tests and internal callers
    should import them directly from the owning sub-module"). No
    code change required for R3 in this PR.

Test surface reorganized to match the code surface:
- `tests/utils/HostUtils.test.ts` (new) — 9 `isLoopback` cases and
  12 `normalizeHost` cases moved from the previous
  `tests/charging-station/ui-server/UIServerNet.test.ts`. Uses a
  direct module import (`../../src/utils/HostUtils.js`) rather than
  the utils barrel: the barrel load pulls Configuration singleton +
  Logger side effects into a pure-function test surface, which under
  the Node test runner produces "Promise resolution is still pending
  but the event loop has already resolved" hangs. Peer utility tests
  that need those side effects use the barrel; a pure host-parsing
  unit test does not.
- `tests/charging-station/ui-server/UIServerNet.test.ts` retains only
  the 4 `splitHeaderList` cases.

Quality gates: typecheck clean, lint clean, targeted tests pass
(utils + ui-server + broadcast-channel suites: 705/705 pass, 0 fail).

3 weeks agorefactor: migrate external deep imports to ui-server and broadcast-channel barrels...
Jérôme Benoit [Sun, 5 Jul 2026 18:41:58 +0000 (20:41 +0200)] 
refactor: migrate external deep imports to ui-server and broadcast-channel barrels (#1953)

Migrate the 2 external cross-sub-component deep imports enabled by the barrels introduced in #1952 to route through the barrel index files per the flat parent barrel convention.

Sites migrated (2):
- Bootstrap.ts:61 UIServerFactory — deep → ui-server/index.js
- ChargingStation.ts:114 ChargingStationWorkerBroadcastChannel —
  deep → broadcast-channel/index.js

Sites deliberately NOT migrated (documented in PR body):
- Bootstrap.ts:13 AbstractUIServer (type) — not re-exported; internal
  extension point per the ui-server barrel's `@file` omission list.
- ConfigurationSchema.ts:7 UIServerNet.isHostLiteralWithoutPort —
  UIServerNet not re-exported; internal helper per the omission list.
- AbstractUIService.ts:33 UIServiceWorkerBroadcastChannel (value) —
  adjacent sub-component crossing. Migrating triggers a circular-load
  ReferenceError at test time because the broadcast-channel barrel
  re-exports both concrete channels, transitively pulling
  ChargingStationWorkerBroadcastChannel's charging-station chain back
  through ui-server before class declarations complete.
- UIServiceWorkerBroadcastChannel.ts:1 AbstractUIService (type-only) —
  adjacent sub-component crossing; kept direct for symmetry.

Convention codified: barrels are for CROSS-COMPONENT external
consumers, not for adjacent-sub-component crossings within the same
parent module. Matches the meter-values/index.ts convention.

Quality gates: typecheck clean, lint clean, 2955/2961 tests pass, 0 fail.

Diff: +2/-2 across 2 files.

Follow-up to #1952 (barrel gaps closure). PR-D' from the sequence
tracked in #1950's scope fence.

3 weeks agorefactor: close barrel gaps for ui-server, broadcast-channel, DEFAULT_PERSIST_STATE...
Jérôme Benoit [Sun, 5 Jul 2026 18:31:05 +0000 (20:31 +0200)] 
refactor: close barrel gaps for ui-server, broadcast-channel, DEFAULT_PERSIST_STATE (#1952)

Close 3 barrel gaps identified during the PR #1950 audit chain.

New barrels (respect the flat parent barrel convention already used by
`src/charging-station/ocpp/index.ts`, `.../meter-values/index.ts`,
`src/performance/index.ts`):

- `src/charging-station/ui-server/index.ts` — exposes UIMCPServer,
  UIServerFactory, UIWebSocketServer, HttpMethod (UIServerUtils),
  DEFAULT_COMPRESSION_THRESHOLD_BYTES (UIServerSecurity),
  AbstractUIService, BroadcastChannelResponseLogContext. Deliberately
  omits UIHttpServer (@deprecated pending removal), AbstractUIServer
  (internal extension point), UIServerAccessPolicy/UIServerNet
  helpers, and ui-services concrete implementations — all documented
  in the `@file` JSDoc header.

- `src/charging-station/broadcast-channel/index.ts` — exposes
  ChargingStationWorkerBroadcastChannel and
  UIServiceWorkerBroadcastChannel. Deliberately omits
  WorkerBroadcastChannel (abstract base with only internal subclasses)
  — documented in the `@file` JSDoc header.

Both barrels carry an `@file` header matching the peer convention
(`meter-values/index.ts`, `ocpp/auth/index.ts`) that documents what is
exposed AND what is deliberately NOT re-exported and why — anchoring
the exposure discipline in the file itself.

Extended barrel:
- `src/utils/index.ts`: add `DEFAULT_PERSIST_STATE` to the
  `Configuration` re-export block. Closes an Explore-flagged barrel
  gap prospectively; zero current callers affected.

No consumer migrations in this PR — tests inside the sub-components
follow the "test-of-that-file uses direct import" precedent from
PR #1950. Source-to-source deep imports (5 sites: Bootstrap.ts,
ChargingStation.ts, ConfigurationSchema.ts,
UIServiceWorkerBroadcastChannel.ts, AbstractUIService.ts) are deferred
to a follow-up PR-D' to keep this PR focused on barrel infrastructure.

Quality gates: typecheck clean, lint clean, 577/577 targeted tests
pass (ui-server + broadcast-channel + Configuration suites).

Diff: +41/-1 across 3 files.

Reviews applied (3 rounds, 4 agents each): 1 substantive convergent
finding v2 addressed (JSDoc headers on both new barrels); v3 saturation
HIGH declared by 4/4 agents (0 substantive findings, 6 false positives
rejected via caller-trace).

3 weeks agofix(utils): clamp computeExponentialBackOffDelay output to setTimeout-safe range...
Jérôme Benoit [Sun, 5 Jul 2026 18:30:45 +0000 (20:30 +0200)] 
fix(utils): clamp computeExponentialBackOffDelay output to setTimeout-safe range (#1951)

Fix a latent overflow bug in `computeExponentialBackOffDelay` that turned intended exponential backoff into a tight-loop retry storm at high retry counts.

The function returned `baseDelayMs * 2^retry + jitter` unclamped. With
`baseDelayMs = 100` (default) it overflows `MAX_SETINTERVAL_DELAY_MS`
(2_147_483_647 ms, ~24.8 days) at retry ≥25; with `baseDelayMs = 60_000`
(BootNotification interval default) it overflows at retry ≥16. Node.js
`setTimeout` silently coerces out-of-range delays to 1 ms per its docs,
producing a tight-loop reconnect/retry storm instead of the intended
exponential wait.

Fix: wrap the return value with `clampToSafeTimerValue()` inside
`computeExponentialBackOffDelay`. Single-site DRY defense covering all
5 present and future callers.

Call sites verified (5):
- ChargingStation.ts:1608 getReconnectDelay — vulnerable
  (wsConnectionRetryCount unbounded when autoReconnectMaxRetries = -1)
- ChargingStation.ts:2532 onOpen BootNotification retry — vulnerable
  (registrationRetryCount unbounded when registrationMaxRetries = -1)
- ChargingStation.ts:2788 sendMessageBuffer — vulnerable
  (messageIdx unbounded)
- OCPP20ServiceUtils.ts:253 computeReconnectDelay — safe by
  construction (maxRetries: RetryBackOffRepeatTimes)
- OCPP20CertSigningRetryManager.ts:96 scheduleNextRetry — safe by
  construction (maxRetries: CertSigningRepeatTimes)

Extends `computeExponentialBackOffDelay` JSDoc `@returns` to advertise
the clamp guarantee explicitly:
`delay in milliseconds, guaranteed within [0, Constants.MAX_SETINTERVAL_DELAY_MS]`.

Regression tests added at retry 25 (base 100 ms), retry 30 (extreme),
and a 20-sample jitter loop at retry 25 with `jitterPercent: 0.2`.
At retry ≥25 the pre-jitter delay already exceeds the ceiling, so the
jitter loop asserts strict equality with MAX (deterministic).

Priority-3 anchor comments preserved: mathematical formula anchor for
the overflow boundary and clamp-after-jitter ordering invariant.

Quality gates: typecheck clean, lint clean, tests/utils/Utils.test.ts
54/54 pass.

Diff: +34/-3 across 2 files.

Reviews applied (3 rounds, 4 agents each): 8 substantive findings
across v1+v2 addressed; v3 saturation HIGH declared by 4/4 agents
(0 substantive findings, 6 false positives rejected via caller-trace).

3 weeks agorefactor: convention alignment — helpers, constants, imports, tests (#1950)
Jérôme Benoit [Sun, 5 Jul 2026 17:20:58 +0000 (19:20 +0200)] 
refactor: convention alignment — helpers, constants, imports, tests (#1950)

Convention alignment refactor across the monorepo — no behavior change.

Helpers adoption:
- adopt isEmpty() helper for emptiness checks (9 sites); OCPPServiceUtils.ts:1113 preserved with anchor comment (already-trimmed input; isEmpty would redundantly re-trim)
- adopt Constants.UNIT_DIVIDER_KILO for kW→W conversion (4 sites)
- use JSONStringify for unknown MCP tool payload (Map/Set-safe replacer)

Constants promotion:
- promote inline literals to named constants (MAX_ITEMS_PER_REPORT_MESSAGE with OCPP 2.0.1 §2.1.16/§2.1.18 spec-anchor comment; AVG_ENTRY_SIZE_BYTES; DEFAULT_EXPONENTIAL_BACKOFF_BASE_DELAY_MS; DEFAULT_AUTH_CACHE_TTL_SECONDS; DEFAULT_PERSIST_STATE)
- coverage gap: isNotEmptyArray adoption at ConfigurationValidation.ts:124

Imports discipline:
- add .js extension to relative import in ui-web/vitest.config (ESM/NodeNext)
- use component barrels instead of deep imports in tests (10 sites)

Test literals:
- use DEFAULT_HOST/DEFAULT_PORT constants in ui-common tests (72 sites: 32 in config.test.ts + 40 in WebSocketClient.test.ts)

Documentation anchors:
- document Number.parseInt/parseFloat convention exceptions (3 comments covering 4 call sites where convertToInt/convertToFloat cannot substitute)

Quality gates: typecheck clean, lint clean, 2954/2960 tests pass, 0 fail.

Diff: +140/-113 across 28 files.

3 weeks agorefactor(charging-station,meter-values): close #1936 (M-08 + f-2..f-5 + M-09) (#1949)
Jérôme Benoit [Sun, 5 Jul 2026 15:15:52 +0000 (17:15 +0200)] 
refactor(charging-station,meter-values): close #1936 (M-08 + f-2..f-5 + M-09) (#1949)

* refactor(tests): split StationHelpers.ts into modular files (issue #1936 M-08)

Split the 962-LOC monolithic StationHelpers.ts into five focused modules:
- StationHelpers.types.ts (149 LOC): 7 interfaces + MockChargingStation type
- StationHelpers.cleanup.ts (160 LOC): cleanup + reset helpers
- StationHelpers.connector.ts (65 LOC): connector-status + EVSE-usage helpers
- StationHelpers.template.ts (20 LOC): mock template factory
- StationHelpers.factory.ts (607 LOC): createMockChargingStation factory

StationHelpers.ts is now a pure re-export barrel preserving the public API,
so all 77 consumer test files keep importing from './StationHelpers.js'
without change. Mechanical move only: no logic, signature, or behavior
change.

determineEvseUsage is now exported (previously module-private) because it
is consumed by the factory across module boundaries.

* feat(meter-values): add powerFactor + rampShape to EvProfile (issue #1936 M-09)

Two optional EvProfile refinements. Both default to current behavior so
existing profiles and golden tests are unchanged.

- powerFactor (0, 1], absent => 1: cos phi factor. Per-phase current
  becomes I = P / (V . phases . powerFactor); INV-1 extends to
  P_active = V.I.phases.powerFactor.
- rampShape 'linear'|'sigmoid', absent => 'linear': sigmoid uses a
  shifted-scaled logistic (k=10) pinned at f(0)=0 and f(1)=1 exactly
  after endpoint normalization.

* refactor(charging-station): extract charging-profile helpers to HelpersChargingProfile (#1936 f-2)

Split Helpers.ts (Phase 2 of 5). Moves 14 charging-profile symbols into
sibling HelpersChargingProfile.ts and re-exports the public ones from
Helpers.ts so the barrel import path is preserved. Zero behavior change:
same signatures, same code, same log content. The moduleName local in
the new file switches log prefixes from 'Helpers.<fn>' to
'HelpersChargingProfile.<fn>', reflecting the actual source location.

Moved (private inside new file):
- getChargingStationChargingProfiles, buildChargingProfilesLimit,
  ChargingProfilesLimit interface, getChargingProfileId,
  getChargingProfilesLimit, canProceedRecurringChargingProfile,
  prepareRecurringChargingProfile, checkRecurringChargingProfileDuration

Moved (exported from new file):
- getChargingStationChargingProfilesLimit, getConnectorChargingProfiles,
  getConnectorChargingProfilesLimit, prepareChargingProfileKind,
  canProceedChargingProfile (all re-exported from Helpers.ts)
- getSingleChargingSchedule (exported for Phase 3 direct import; not
  re-exported from Helpers.ts to keep the public API unchanged)

Follows the barrel pattern established by HelpersReservation.ts in
Phase 1 (PR #1946).

* refactor(charging-station): extract connector-status helpers to HelpersConnectorStatus (#1936 f-3)

Split Helpers.ts (Phase 3 of 5). Moves 8 connector-status symbols into
sibling HelpersConnectorStatus.ts and re-exports the public ones from
Helpers.ts so the barrel import path is preserved. Zero behavior change:
same signatures, same code, same log content. The moduleName local in
the new file switches log prefixes from 'Helpers.<fn>' to
'HelpersConnectorStatus.<fn>', reflecting the actual source location.

Moved (private inside new file):
- initializeConnectorStatus

Moved (exported from new file, re-exported from Helpers.ts):
- buildConnectorsMap, initializeConnectorsMapStatus, resetConnectorStatus,
  resetAuthorizeConnectorStatus, prepareConnectorStatus,
  checkStationInfoConnectorStatus, getBootConnectorStatus

Follows the barrel pattern established by HelpersReservation.ts in
Phase 1 (PR #1946).

* refactor(charging-station): extract serial-number/id helpers to HelpersId (#1936 f-4)

Extracts 4 identity helpers (getChargingStationId, getHashId, createSerialNumber,

propagateSerialNumber) plus the ChargingStationNameTemplate type alias into a

dedicated HelpersId.ts. The getRandomSerialNumberSuffix helper had a single

internal caller (createSerialNumber) and no external consumer; it is demoted to

module-private in the new file, matching its actual usage.

Helpers.ts continues to re-export the 4 public symbols and the type alias via

the barrel, so all 5 consumer files work unchanged.

Refs #1936

* refactor(charging-station): extract config/template helpers to HelpersConfig (#1936 f-5)

Extracts 10 configuration/template helpers (buildTemplateName, validateStationInfo,

getDefaultConnectorMaximumPower, checkConfiguration, setChargingStationOptions,

stationTemplateToStationInfo, getAmperageLimitationUnitDivider, getDefaultVoltageOut,

getIdTagsFile, getEvProfilesFile) plus the two template-topology helpers

getMaxNumberOfEvses and getMaxNumberOfConnectors into HelpersConfig.ts.

The two template-topology helpers were originally listed as core-station helpers

in the issue body, but getDefaultConnectorMaximumPower consumes both of them;

moving them into HelpersConfig avoids a cross-module dependency back into

Helpers.ts that would risk an ESM live-binding cycle. Helpers.ts imports

getMaxNumberOfConnectors from HelpersConfig for use in the retained

getConfiguredMaxNumberOfConnectors, closing the split with no cycle.

Helpers.ts is now 185 LOC (core station helpers + 5 barrel re-exports),

down from 1389 LOC before the phased split.

Refs #1936

* [autofix.ci] apply automated fixes

* fix(charging-station,meter-values): apply Round 1 review fixes to #1949 (issue #1936)

1. HelpersConnectorStatus.ts imported getMaxNumberOfConnectors via the

   Helpers.js barrel, which re-exports it from HelpersConfig.js while

   also re-exporting HelpersConnectorStatus.js. That is the exact ESM

   barrel cycle the config/template extraction was designed to avoid.

   Import getMaxNumberOfConnectors directly from HelpersConfig.js to

   keep the dependency direction one-way.

2. EvProfile powerFactor Zod schema tightened from (0, 1] to [0.5, 1].

   The previous positive() lower bound accepted tiny values that made

   the divisor V*phases*powerFactor collapse toward zero and produce

   non-physical current. Real onboard chargers sit at 0.98..1.0; the

   0.5 floor blocks configuration errors while keeping physically

   defensible room. Reflected in JSDoc, README, and the schema.

3. powerFactor is now AC-only. DC has no reactive component (P=V*I),

   so applying cos phi on DC profiles produces non-physical current.

   Gate on session.currentType === CurrentType.AC when computing the

   divisor; DC pins powerFactor to 1 regardless of the profile field.

   Test locks the DC-gate contract with an explicit regression case.

4. sigmoidRamp JSDoc reworded. Endpoint bit-exactness is pinned by

   the short-circuit paths at progress <= 0 and >= 1; the shift-scale

   normalization aligns interior open-interval values. The prior

   wording overstated what the normalization does on its own.

5. StationHelpers.ts barrel comment reworded to describe the public

   surface directly instead of framing it as backward compatibility.

Refs #1936

* fix(charging-station,meter-values): apply Round 2 review fixes to #1949 (issue #1936)

1. INV-1 DC docstring stale: CoherentSampleComputer.ts @file block said

   DC: P = V*I*powerFactor but the implementation and README both pin

   powerFactor to 1 on DC (no reactive component in DC). Corrected to

   DC: P = V*I and cross-referenced the AC-only gate.

2. resolvePhasedValue non-emission of OCPP measurands documented:

   Power.Factor and Power.Reactive.Import are defined in OCPP 1.6 and

   2.0.1 measurand enums but the coherent path does not emit them.

   EvProfile.powerFactor scales the AC current/power chain internally

   but is not surfaced as Power.Factor. Templates configuring these

   measurands under coherent mode are skipped with a warning. The

   JSDoc now lists the supported measurand set and calls out the

   unsupported ones explicitly.

3. propagateSerialNumber (public export via Helpers barrel) gained a

   JSDoc block with @throws {BaseError}, matching the coverage pattern

   established for HelpersConfig.ts public exports.

4. buildChargingProfilesLimit (module-private but non-trivial) gained

   a JSDoc block with @throws {BaseError} for parallelism.

5. Test describe block renamed from 'rampShape sigmoid' to

   'rampShape sigmoid' with the literal quoted, matching the string-

   literal-type convention used everywhere else for rampShape values.

Refs #1936

* docs(charging-station): complete R2 findings coverage on helper split (#1936)

Applies design decisions for all remaining R2 findings on PR #1949:

1. JSDoc completeness (Lane A M-R2.2 + Lane B M-R2.6): every public

   export re-exported through the Helpers.ts barrel now carries a

   symbol-level JSDoc block matching the HelpersConfig.ts style

   established during Phase 5 extraction. Coverage: 3 exports in

   HelpersId.ts (getChargingStationId, getHashId, createSerialNumber),

   5 exports in HelpersChargingProfile.ts (getSingleChargingSchedule,

   getChargingStationChargingProfilesLimit,

   getConnectorChargingProfilesLimit, prepareChargingProfileKind,

   canProceedChargingProfile), and 7 exports in HelpersConnectorStatus.ts

   (getBootConnectorStatus, checkStationInfoConnectorStatus,

   buildConnectorsMap, initializeConnectorsMapStatus,

   resetAuthorizeConnectorStatus, resetConnectorStatus,

   prepareConnectorStatus). ChargingStationNameTemplate type also

   documented.

2. Barrel style divergence (Lane A N-R2.1): the two barrels use

   different re-export shapes on purpose. Helpers.ts uses explicit

   name lists to control and document the external API surface;

   StationHelpers.ts uses 'export *' because its consumers are

   internal to the test tree. A one-line justification comment now

   lives in each barrel so the divergence is intentional-by-record.

3. HelpersId moduleName absence (Lane A N-R2.3): every helper in

   this file is pure (no logger.* calls), so declaring an unused

   moduleName constant would violate the no-dead-code convention.

   Documented as an explicit design decision in the @file block.

Refs #1936

* docs(charging-station): rewrite R2b JSDoc blocks that misdescribed the code (#1936)

R3 review caught 5 R2b JSDoc blocks whose narrative did not match the

actual code path. Every block is rewritten to reflect current behavior:

1. getSingleChargingSchedule: the code returns undefined for ANY array

   shape (OCPP 2.0.x), not just for zero-or-multi-entry arrays. Doc now

   states arrays are logged (debug) and skipped, only the OCPP 1.6

   single-schedule shape is consumed. The pre-existing OCPP 2.0.x

   array-shape handling gap is out of scope for this PR.

2. getChargingStationChargingProfilesLimit: the code filters

   CHARGE_POINT_MAX_PROFILE (OCPP 1.6 value). Doc now uses that name

   explicitly and calls out that the OCPP 2.0.1 equivalent

   ChargingStationMaxProfile is not handled by this filter. Also

   out-of-scope pre-existing behavior.

3. getBootConnectorStatus: doc claimed a fallback to Available, but

   the code returns Unavailable when the station or connector is

   unavailable. Doc now enumerates the four branches explicitly

   (Unavailable / persisted status / bootStatus / Available).

4. initializeConnectorsMapStatus: doc had two independent errors.

   Connector 0 is mutated (availability=Operative, chargingProfiles

   default to empty), not left untouched. And initializeConnectorStatus

   runs on the transactionStarted-unset branch, not on the pending

   flag branch. Doc now describes all four actual branches.

5. resetConnectorStatus: doc said energy counters (plural); only the

   transaction-scoped register is reset. Doc said connector identity

   (id, availability) is preserved; ConnectorStatus has no id field,

   only the outer Map key. Doc now says transaction-scoped energy

   counter and preserves availability only.

Also replaced {@link initializeConnectorStatus} references with prose

since the target is module-private and would emit unresolved-reference

warnings from TypeDoc.

Refs #1936

* fix(charging-station): close R3 findings — OCPP 2.0.x profile support + Readonly type (#1936)

Applies the 3 remaining R3 findings as proper code fixes (not docs-only):

1. getSingleChargingSchedule unwraps length-1 OCPP 2.0.x arrays.

   Previously any array shape returned undefined, so all 8 callers

   silently dropped OCPP 2.0.x profiles at limit-resolution and

   preparation time. The fix unwraps chargingSchedule[0] when the

   array has exactly one entry (the OCPP 2.0.x single-schedule shape),

   and keeps the debug log + skip path for zero or multi-entry arrays

   (2-3 concurrent schedules are valid per spec but the coherent path

   does not pick between them). JSDoc rewritten to describe the

   post-fix contract. 4 regression tests added: OCPP 1.6 shape,

   length-1 unwrap, length-2 skip, empty-array skip.

2. getChargingStationChargingProfilesLimit filter now accepts both

   ChargingProfilePurposeType.CHARGE_POINT_MAX_PROFILE (OCPP 1.6

   value 'ChargePointMaxProfile') and

   ChargingProfilePurposeType.ChargingStationMaxProfile (OCPP 2.0.1

   value 'ChargingStationMaxProfile'). Previously only the OCPP 1.6

   value matched, so OCPP 2.0.1 station-scope profiles were excluded

   from station-level limit resolution. JSDoc updated.

3. ChargingStationNameTemplate is now Readonly<Pick<...>>. The type

   was already used read-only by getChargingStationId; the Readonly

   wrapper makes the pure-read contract explicit at the type level.

Note: findings 1 and 2 are pre-existing behavior gaps exposed by the

R2b/R3 JSDoc accuracy audit, not regressions introduced by the

refactor. Fixing them here rather than deferring to a follow-up issue

matches the session precedent (PR-J F06.FR.06, PR-K

RegisterValuesWithoutPhases, PR-H physics refinements).

Refs #1936

* fix(charging-station): close R4 findings, add station-scope filter regression tests (#1936)

R4 review surfaced two convergent findings on the R3b station-scope

profile filter fix:

1. N-R4.1: the R3b filter accepted CHARGE_POINT_MAX_PROFILE (OCPP 1.6)

   and ChargingStationMaxProfile (OCPP 2.0.1), but OCPP 2.0.1 has a

   second station-scope purpose ChargingStationExternalConstraints

   (OCA J01 use-case: external LMS/EMS-imposed caps). The local

   validator at OCPP20IncomingRequestService.ts:4157-4164 already

   treats it identically to ChargingStationMaxProfile (must apply to

   EVSE 0). Filter now accepts all three station-scope purposes.

   JSDoc updated to describe the OCA J01 semantic distinction and the

   coherent path's decision to treat both OCPP 2.0.1 purposes as

   equivalent inputs to stack-level tie-break.

2. N-R4.3: R3b Fix #2 had no regression test locking the filter

   acceptance. 4 targeted tests added covering the 3 station-scope

   purposes (CHARGE_POINT_MAX_PROFILE, ChargingStationMaxProfile,

   ChargingStationExternalConstraints) plus a negative TX_PROFILE

   rejection. Tests seed connector 0's chargingProfiles directly via

   MockChargingStation and assert getChargingStationChargingProfilesLimit

   returns the expected watt limit or undefined.

Refs #1936

* docs(charging-station): apply Round 5 review fixes to #1949 (issue #1936)

R5 review surfaced one MAJOR spec-citation error and three test-quality

MINORs. Only actionable items are fixed here; prior R4b commit body is

immutable per session convention (squash-safe branch history).

1. M1 spec citation: OCA J01 in R4b JSDoc is factually wrong. J01 is the

   Metering functional block (Sending Meter Values not related to a

   transaction). ChargingStationExternalConstraints belongs to the K

   SmartCharging block: K04 for the internal Load Balancing use-case

   (matches ChargingStationMaxProfile semantic) and K11-K14 for the

   External Charging Limit family that the station reports to the CSMS

   (matches ChargingStationExternalConstraints semantic). JSDoc rewrites

   the citation to K04 vs K11-K14.

2. n2 test coverage: getSingleChargingSchedule regression tests missed

   the OCPP 2.0.1 spec upper bound of a length-3 chargingSchedule array

   (spec cardinality is 1..3). Added one test verifying the length-3

   case returns undefined (matching the current always-skip-non-length-1

   behavior).

3. n3 test coverage: the R4b station-scope filter tests all seeded a

   single profile at stackLevel 0, missing (a) tie-break correctness

   between two station-scope profiles at different stack levels and (b)

   filter + purpose interaction when a station-scope and a TX_PROFILE

   are both seeded on connector 0. Added two tests covering both cases.

4. n1 test setup rationale: the seed helper reassigns station.stationInfo

   after mock construction. This only reaches code paths that read

   chargingStation.stationInfo directly (the maximumPower sanity check);

   factory methods like getNumberOfPhases and getVoltageOut capture the

   constructor-time options in a closure and do not observe the mutation.

   Tests use ChargingRateUnitType.WATT to bypass AC/DC conversion, so

   currentOutType is not exercised. Added an inline comment locking the

   design decision so future contributors know why the mutation is inert

   for phase/voltage getters.

Renamed the seed helper from seedConnectorZeroWithProfile (singular) to

seedConnectorZeroWithProfiles (plural) to fit the multi-profile cases.

Refs #1936

* docs(charging-station): refine R5b OCA citation with K08.FR.04 merge behavior (#1936)

R6 review surfaced one MINOR precision gap in the R5b OCA citation and

one false-positive on issue linkage (rejected — issue #1936 IS the

correct closing target per session-verified reconciliation of 11

coordinated PRs).

R5b already fixed OCA J01 (Metering) to OCA K04 (Internal LMS) and

OCA K11-K14 (External Charging Limit). R6 Lane A + Lane B convergent

on a semantic distinction: K11-K14 describes the profile's ORIGIN

(external system sets the limit, station stores it internally), not

the coherent path's BEHAVIOR (composite-schedule merge input). The

authoritative citation for the merge-input behavior is OCA K08.FR.04

(Get Composite Schedule) and safety invariant SC.01. R5b also called

ChargingStationExternalConstraints an LMS/EMS-imposed cap the

station reports to the CSMS — accurate for the reporting flow via

NotifyChargingLimit / ReportChargingProfiles, but the code path uses

the profile as merge input rather than emit-source.

JSDoc now cites both K11-K14 (origin: external system sets limit,

station stores internally, reports upstream to CSMS) and K08.FR.04 /

SC.01 (behavior: composite-schedule merge input consumed by the

coherent path).

Refs #1936

* test(charging-station,meter-values): apply Round 7 test-strengthening fixes to #1949 (#1936)

R7 review surfaced two valid regression-proof gaps in the test suite

(other Lane B claims rejected as too-strict interpretation of boundary

and purpose-guard tests). Only actionable strengthening applied here.

1. Sigmoid ramp test at CoherentMeterValues.test.ts:1782+ only pinned

   endpoints (0, 1) via short-circuit and midpoint (0.5) via symmetry —

   all three points collapse to the same values on linear ramp. A

   silent revert of the sigmoid branch back to linear would pass the

   assertion. Added two interior-curvature assertions at rampUpDurationMs/4

   and 3*rampUpDurationMs/4 where sigmoid (k=10) emits ~0.07 and ~0.93

   respectively vs linear 0.25 and 0.75. Tolerance 0.15/0.85 sits well

   below the ~0.18 sigmoid-vs-linear gap, so the sigmoid feature is now

   contractually locked at interior points.

2. Readonly contract on ChargingStationNameTemplate had no regression

   lock. Added a compile-time assertion via @ts-expect-error on a

   mutation attempt. If the Readonly<Pick<...>> wrapper is silently

   removed, tsc --noEmit fails with 'Unused @ts-expect-error directive'

   at build time. Runtime tolerates the mutation (Readonly is

   compile-time only); the compile-time directive is the regression lock.

Refs #1936

* docs(charging-station): fix HelpersId JSDoc warnings on createSerialNumber (#1936)

pnpm format surfaced 3 lint warnings on HelpersId.ts:createSerialNumber:

- cspell/spellchecker: unknown word 'uppercased'

- jsdoc/require-param-description: missing params.randomSerialNumber description

- jsdoc/require-param-description: missing params.randomSerialNumberUpperCase description

Rewrote the JSDoc to spell 'upper case' with a space (standard English)

and added explicit descriptions for the two nested params. pnpm format

and pnpm lint are now warning-free.

Refs #1936

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
3 weeks agofeat(meter-values): evse-level MeterValues template fallback in coherent path (issue...
Jérôme Benoit [Sun, 5 Jul 2026 00:06:37 +0000 (02:06 +0200)] 
feat(meter-values): evse-level MeterValues template fallback in coherent path (issue #1936 g) (#1944)

* feat(meter-values): evse-level MeterValues template fallback in coherent path (issue #1936 g)

The coherent path's `resolveTemplates` at `CoherentMeterValueBuilder.ts`
read connector-level `MeterValues` only. A station that defined
`MeterValues` at EVSE level (via the `Evses` template section) and
enabled `coherentMeterValues=true` would emit an empty MeterValue
because the EVSE-level array was never consulted — a pre-existing TODO
documented in the prior `resolveTemplates` JSDoc: "Adding EVSE-level
template inheritance to the coherent path requires extending the
context interface with `getEvseStatus`."

Fix: mirror the random/fixed path's `getSampledValueTemplate` semantics
(README section "MeterValues at EVSE level": "EVSE-level definitions
apply to all connectors of the EVSE and override connector-level
definitions"). EVSE-level `MeterValues`, when defined and non-empty,
override connector-level `MeterValues` for every connector under the
EVSE; connector-level `MeterValues` are used when the connector is not
grouped under an EVSE (flat `Connectors` map station layout) or when
the EVSE-level array is empty.

- Extend `ICoherentContext` with two accessors mirroring the existing
  `ChargingStation` public API: `getEvseIdByConnectorId(connectorId)`
  and `getEvseStatus(evseId)`. Requires importing `EvseStatus` from
  the shared types barrel.
- Rewrite `resolveTemplates` in `CoherentMeterValueBuilder.ts` to
  perform the EVSE-first lookup and fall back to connector-level when
  the EVSE is undefined or its `MeterValues` array is missing/empty.
  JSDoc updated to reflect the new contract.
- Extend the in-file `buildContext` test factory with an optional
  `evseMeterValues` override (implies `getEvseIdByConnectorId`
  resolves to EVSE id 1 and `getEvseStatus(1)` returns
  `{ MeterValues: overrides.evseMeterValues, ... }`; falsy override
  leaves `getEvseIdByConnectorId` returning `undefined`).
- Add an `EVSE-level template fallback` describe block with two
  tests: (a) EVSE-level MeterValues override connector-level when
  defined, (b) connector-level MeterValues used when no EVSE
  grouping exists.
- Update README section "Template resolution scope" to remove the
  "connector-level definition only" caveat and document the new
  precedence rules.

The shared mock in `tests/charging-station/helpers/StationHelpers.ts`
already exposes both accessors; downstream test files routing through
the mock require no change.

Test invariant `fail: 0, skipped: 6` preserved.

Closes issue #1936 item (g).

* docs(meter-values): clarify EVSE fallback semantics + add empty-EVSE test (issue #1936 g)

R1 Lane A (Oracle) surfaced two follow-ups on the initial R1 rebase
implementation of the EVSE-first template lookup:

1. The `resolveTemplates` JSDoc and README wording claimed "the same
   precedence as ... `getSampledValueTemplate`". That claim was
   inaccurate on a subtle edge case: the random/fixed reference at
   `OCPPServiceUtils.ts:1546-1563` aggregates `MeterValues` across all
   sibling connectors under the same EVSE when `EvseStatus.MeterValues`
   is empty. The coherent path deliberately does NOT do this - a
   connector without its own `MeterValues` returns `undefined` under
   the coherent path, whereas the reference would leak sibling
   templates. This stricter isolation is desirable design (per-connector
   ownership), but the parity claim overstated the match.

   Wording weakened accordingly: JSDoc + README now explicitly state
   that the coherent generator emits templates from exactly one source
   (EVSE-level or the queried connector) and does not aggregate across
   sibling connectors, calling out the divergence from the reference
   path.

2. The initial R1 rebase covered "EVSE-level overrides connector-level
   when defined" and "connector-level used when no EVSE grouping" but
   left the third semantic branch untested: EVSE grouping present +
   `EvseStatus.MeterValues` is an empty array. The `resolveTemplates`
   guard `evseTemplates != null && evseTemplates.length > 0` explicitly
   handles this case by falling back to connector-level, but no test
   asserted the branch.

   New test added: `should fall back to connector-level MeterValues
   when EVSE grouping exists but EVSE-level MeterValues array is
   empty`. Configures `evseMeterValues: []` in `buildContext` (which
   makes `getEvseIdByConnectorId` return `1` and
   `getEvseStatus(1).MeterValues = []`), asserts connector-level
   template emits.

No code change to `resolveTemplates`; the empty-EVSE branch was
already correct.

Test invariant `fail: 0, skipped: 6` preserved.

* test(meter-values): cover EVSE-grouped + undefined MeterValues fallback (issue #1936 g)

R2 Lane B (adversarial) noted that the previous 3-test coverage in the
`EVSE-level template fallback` describe block conflated two distinct
cases: "no EVSE grouping" and "EVSE grouping present with
`MeterValues` undefined". Both eventually fall back to connector-level,
but through different code paths in `resolveTemplates`:

- "No EVSE grouping": `getEvseIdByConnectorId` returns `undefined` -
  the outer `if (evseId != null)` guard fails, fallback immediate.
- "EVSE grouping + MeterValues undefined": `getEvseIdByConnectorId`
  returns `1`, `getEvseStatus(1).MeterValues` is `undefined`, so the
  inner `evseTemplates != null && evseTemplates.length > 0` guard
  fails (both undefined and empty-array branches share the fallback).

The prior mock factory heuristic `overrides.evseMeterValues != null ?
1 : undefined` could express the first two cases plus "grouping +
empty array" but NOT "grouping + undefined MeterValues" - those
required distinct mock states.

Redesigned factory:
- Add an explicit `groupUnderEvse?: boolean` knob that overrides the
  `evseMeterValues != null` heuristic when set.
- `groupUnderEvse === true` forces `getEvseIdByConnectorId` to return
  `1` regardless of `evseMeterValues`, enabling the "grouping +
  undefined MeterValues" state via `groupUnderEvse: true` alone.
- `groupUnderEvse === false` forces flat-connector layout even with
  `evseMeterValues` defined (fully explicit override).
- `groupUnderEvse === undefined` preserves the prior heuristic
  (backward-compat for existing tests).

New test added: `should fall back to connector-level MeterValues when
EVSE grouping exists but EVSE-level MeterValues is undefined`. Uses
`groupUnderEvse: true` without `evseMeterValues` to exercise the
distinct-from-empty-array branch.

Test invariant `fail: 0, skipped: 6` preserved.

* docs(meter-values): sharpen resolveTemplates JSDoc + adopt isNotEmptyArray helper (issue #1936 g)

R8 (comprehensive final review, user-directed expanded criteria) surfaced
2 tightly related follow-ups on the R1-established `resolveTemplates`
implementation:

1. Lane C MINOR (JSDoc precision drift): the divergence NOTE narrowed
   the condition under which the random/fixed path aggregates
   sibling-connector templates to "both EVSE-level and the queried
   connector's `MeterValues` are empty". The reference
   `getSampledValueTemplate` (`OCPPServiceUtils.ts:1546-1559`) actually
   aggregates whenever `isNotEmptyArray(evseStatus.MeterValues)` is
   false, which covers `undefined` and `[]` regardless of the queried
   connector's state. The NOTE was strictly narrower than reality; the
   README and PR body already used the broader wording, leaving only
   the JSDoc drifted.

   NOTE rewritten: "when EVSE-level `MeterValues` is undefined or
   empty" (matches reference guard exactly), with the source clause
   correspondingly rephrased "EVSE-level when non-empty, otherwise the
   queried connector".

2. Lane A NIT (harmonization with cross-linked sibling): `resolveTemplates`
   used inline `evseTemplates != null && evseTemplates.length > 0`; the
   explicitly cross-linked sibling `getSampledValueTemplate` uses
   `isNotEmptyArray(evseStatus.MeterValues)` for the identical guard.
   `isNotEmptyArray` is a repo-wide type guard (`Utils.ts:411`,
   `value is NonEmptyArray<T> | ReadonlyNonEmptyArray<T>`) that also
   narrows the return type. Behavioral equivalence, better locality
   with sibling code, one less inline predicate.

   Guard rewritten to `if (isNotEmptyArray(evseTemplates))`; the utils
   barrel import now includes `isNotEmptyArray`.

No behavioral change. Test invariant `fail: 0, skipped: 6` preserved.

* test(meter-values): lock non-aggregation divergence + terminology hyphenation (issue #1936 g)

R9 (post-R8-fix comprehensive re-review, user-directed expanded criteria)
surfaced 3 follow-ups on the R8-committed branch state:

1. Lane B MINOR (regression test gap): the documented divergence from
   `getSampledValueTemplate` (coherent path does NOT aggregate
   sibling-connector `MeterValues` when EVSE-level is undefined or
   empty) was described in JSDoc, README, and PR body but not
   contractually locked by a test. The prior test harness had a
   single-connector EVSE (`connectors: Map([[1, connectorStatus]])`),
   which precluded exercising the sibling-aggregation branch.

   `buildContext` extended with `siblingConnectorMeterValues?:
   SampledValueTemplate[]` knob: when defined, the mock's
   `getEvseStatus(1).connectors` gains a second connector (id 2) with
   the given `MeterValues`. New test
   `should NOT aggregate sibling-connector MeterValues when EVSE-level
   MeterValues is undefined or empty` asserts the coherent path emits
   zero samples when queried connector's `MeterValues` is `[]`,
   EVSE-level `MeterValues` is `[]`, and a sibling connector carries a
   Power template. The reference path would emit that Power template;
   the coherent path must not.

2. Lane B NIT (stale test comment): the comment at
   `CoherentMeterValues.test.ts:1500-1503` still named the pre-R8
   inline guard `evseTemplates != null && length > 0`. Updated to
   reference `isNotEmptyArray(evseTemplates)` matching the current
   implementation at `CoherentMeterValueBuilder.ts:335-337`.

3. Lane B SUB-THRESHOLD (README hyphenation): `README.md:367,369,409`
   used `EVSE level` / `connector level` unhyphenated, drifting from
   the hyphenated `EVSE-level` / `connector-level` used at
   `README.md:489` and throughout the JSDoc/PR body/tests. Normalized
   the 3 remaining occurrences to the hyphenated form for repo-wide
   terminology consistency.

Local report `.omo/reviews/PR-1944.md` also refreshed post-R8 append
drift (Convergence Summary claimed "7 rounds" when 8 were now
documented; R8 consolidation arithmetic `12+ + 6 + 9 = 27+` was
written as `21+`).

Test invariant `fail: 0, skipped: 6` preserved (37/37 pass in target
file including the new sibling-aggregation regression test).

4 weeks agofeat(meter-values): honor RegisterValuesWithoutPhases in coherent path (issue #1936...
Jérôme Benoit [Sat, 4 Jul 2026 22:21:37 +0000 (00:21 +0200)] 
feat(meter-values): honor RegisterValuesWithoutPhases in coherent path (issue #1936 k) (#1942)

* feat(meter-values): honor RegisterValuesWithoutPhases in coherent path (issue #1936 k)

OCPP 2.0.1 `SampledDataCtrlr.RegisterValuesWithoutPhases` suppresses
per-phase L-N emission of `Energy.Active.Import.Register` when set to
`true`. The coherent path previously emitted per-phase register based
solely on the connector template's phase qualifier, ignoring this
config variable (documented at `CoherentMeterValueBuilder.ts` L-N
branch as "not consulted").

Wire the variable through:
- Add `OCPP20OptionalVariableName.RegisterValuesWithoutPhases` to the
  OCPP 2.0.1 optional-variable enum (registry entry already existed).
- Extend `buildCoherentMeterValue` with an optional
  `registerValuesWithoutPhases` parameter (defaults to `false` when
  omitted, preserving current behavior for OCPP 1.6 callers).
- Extend `resolvePhasedValue` with the same flag; the
  `ENERGY_ACTIVE_IMPORT_REGISTER` L-N branch returns `undefined` when
  the flag is `true` so the caller log-and-skips the per-phase
  template, leaving only the aggregate register to emit.
- Wire the flag resolution from `OCPPServiceUtils.buildMeterValue`'s
  coherent strategy gate using the existing `isOCPP20FlagEnabled`
  helper. Safe on OCPP 1.6 stations because the component-scoped
  configuration key never resolves there (returns `false`, preserving
  current behavior).
- Move `OCPP20OptionalVariableName` from the type-only import block
  to the value import block in `OCPPServiceUtils.ts` (previously
  imported as type only; now used as a runtime value).

Three tests added exercising the new behavior:
- L-N per-phase `Energy.Active.Import.Register` templates suppressed
  and only the aggregate register emitted when the flag is `true`.
- Default per-phase L-N emission preserved when the flag is unset.
- `Power.Active.Import` per-phase emission unaffected (flag scoped to
  `Energy.Active.Import.Register`).

README section on coherent-mode phase emission updated to reflect the
new semantics.

Test invariant `fail: 0, skipped: 6` preserved.

Spec citation: OCPP 2.0.1 edition 3 part 2, `SampledDataCtrlr`
component: "If this variable reports a value of true, then meter
values of measurand `Energy.Active.Import.Register` will only report
the total energy over all phases without reporting the individual
phase values."

Closes issue #1936 item (k).

* docs(meter-values): refine OCPP 1.6 safety wire-comment wording (issue #1936 k)

The wire-site comment in `OCPPServiceUtils.ts` at the coherent strategy
gate previously stated:

> "OCPP 1.6 stations never resolve the component-scoped key (returns
> false), preserving current behavior."

`isOCPP20FlagEnabled` reads the raw configuration store via
`getConfigurationKey`. An OCPP 1.6 station whose template literally
set a key named `SampledDataCtrlr.RegisterValuesWithoutPhases` to
`"true"` WOULD resolve it. In practice this is impossible under normal
use (the key is not standard for 1.6), so the safety outcome is
correct, but the "never resolve" wording is not strictly accurate.

Weakened to: "OCPP 1.6 stations do not carry the component-scoped key
by default: `getConfigurationKey` returns `undefined`,
`convertToBoolean(undefined) === false`, so current behavior is
preserved."

Same intent (documenting OCPP 1.6 safety), more precise wording
matching the actual semantic of `isOCPP20FlagEnabled`.

No code change. Test invariant `fail: 0, skipped: 6` preserved.

* refactor(meter-values): apply RegisterValuesWithoutPhases at bucket level (issue #1936 k)

R2 Lane B found two spec-conformance issues in the initial threaded
implementation of `SampledDataCtrlr.RegisterValuesWithoutPhases`:

1. When only per-phase L-N `Energy.Active.Import.Register` templates are
   configured on a connector and the flag is `true`, the previous
   implementation suppressed all three L-N templates and emitted
   nothing for the measurand. This violates the spec:
   > "will only report the total energy over all phases without
   > reporting the individual phase values."
   The spec mandates that the total IS reported; it does not permit a
   silent no-op when the config lacks an aggregate template.

2. `resolvePhasedValue` returned `undefined` for suppressed L-N
   templates, and `buildCoherentMeterValue`'s log-and-skip path treated
   this as an unsupported `(measurand, phase)` combination, emitting a
   spurious `WARN` for every configured L-N register template on
   OCPP 2.0.1 stations with the flag set. This is a legitimate,
   config-driven skip, not an error condition.

Redesign the suppression as a bucket-level pre-filter in
`buildCoherentMeterValue` (before the emit loop) instead of a
per-call return-value negotiation with `resolvePhasedValue`. Both
issues collapse to zero:

- The new helper `applyRegisterValuesWithoutPhases` filters L-N
  templates out of the `Energy.Active.Import.Register` bucket before
  iteration.
- When the connector configured only per-phase L-N templates (no
  aggregate), the helper synthesizes an aggregate template from the
  first suppressed L-N by shallow-cloning it with `phase` cleared;
  `unit`, `measurand`, `location`, and `context` fields inherit from
  the L-N template so the emitted aggregate matches the operator's
  intent for encoding and scale.
- Because L-N templates are removed before iteration, the
  log-and-skip path fires only for genuinely unsupported combinations,
  never for a configured spec suppression.

`resolvePhasedValue` reverts to its pre-`a46c0189` signature (5
parameters, no `suppressPerPhaseRegister` flag) since the flag is now
handled entirely at the bucket boundary. The exported
`buildCoherentMeterValue` retains its `registerValuesWithoutPhases`
parameter and the strategy-gate wire in `OCPPServiceUtils.buildMeterValue`
is unchanged.

One test added exercising the synthesis path:
- `should synthesize aggregate Energy.Active.Import.Register when only
  per-phase L-N templates configured and registerValuesWithoutPhases=true`
  asserts 1 sample survives, the surviving sample has no phase
  qualifier, the value equals the total register (not
  `register / phases`), and the synthesized template inherits the unit
  from the first suppressed L-N.

README updated to describe the pre-filter + synthesis semantic and to
weaken the OCPP 1.6 wording (`getConfigurationKey` returns `undefined`,
`convertToBoolean(undefined) === false`) matching the `4dcb6213`
comment fix.

Test invariant `fail: 0, skipped: 6` preserved.

Spec citation: OCPP 2.0.1 edition 3 part 2, `SampledDataCtrlr`,
`RegisterValuesWithoutPhases`.

* test(meter-values): add OCPP 2.0.1 boundary tests for RegisterValuesWithoutPhases (issue #1936 k)

R5 (maintainer perspective) flagged missing OCPP 2.0 service-boundary
regression tests: the existing suite covered `buildCoherentMeterValue`
directly but not the `buildMeterValue` strategy gate that resolves the
`SampledDataCtrlr.RegisterValuesWithoutPhases` variable and threads it
into the coherent builder on an OCPP 2.0.1 station.

Two tests added to `StrategyDispatch.test.ts`:

1. `should synthesize aggregate register at buildMeterValue boundary
   when the SampledDataCtrlr variable resolves to true`. Uses
   `addConfigurationKey(station, buildConfigKey(...), 'true')` to set
   the component-scoped key exactly as the runtime does, injects a
   3-phase coherent session, configures only per-phase L-N Energy
   templates, calls `buildMeterValue`, and asserts exactly one
   synthesized aggregate sample survives with no phase qualifier.

2. `should preserve per-phase L-N emission when the SampledDataCtrlr
   variable is absent`. Same setup without the configuration key;
   asserts all 3 per-phase L-N samples emit (default behavior
   preserved when the variable does not resolve).

These tests exercise the full wire path: `isOCPP20FlagEnabled` reads
the config store, threads the boolean into `buildCoherentMeterValue`'s
`registerValuesWithoutPhases` parameter, which triggers
`applyRegisterValuesWithoutPhases` bucket pre-filtering and (when
appropriate) aggregate synthesis.

Test invariant `fail: 0, skipped: 6` preserved.

* refactor(meter-values): partition RegisterValuesWithoutPhases by identity family (issue #1936 k)

R8 Lane B (OCPP spec + physics + math adversarial review) surfaced a
correctness gap in the previous `applyRegisterValuesWithoutPhases`
helper: it keyed suppression/synthesis by measurand only, so a single
aggregate `Energy.Active.Import.Register` template anywhere in the
bucket satisfied `hasAggregate` and suppressed per-phase L-N
templates in every other identity family. Example: connector
configured with per-phase INLET templates plus an aggregate OUTLET
template would drop the INLET family entirely and synthesize nothing
for it, violating the spec requirement to report the total energy
per configured family.

Redesign the helper to partition templates into identity families
keyed by `(context, format, location, unit)` before applying
suppression/synthesis:

- Per-phase L-N templates in each family are filtered out (no
  behavioral change per family).
- Within each family that had per-phase L-N templates but no
  aggregate, an aggregate is synthesized from the first suppressed
  L-N template of that family (`phase` cleared, other identity
  fields inherited via shallow spread).
- Families that had no per-phase L-N templates are untouched.
- Result is re-sorted by `PHASE_RANK` to preserve stable emit order.

Extract the `phaseFamily(t.phase) === 'LineToNeutral'` predicate
into `isLineToNeutralTemplate` per R8 Lane A NIT (DRY, drops
redundant `t.phase != null` guard because `phaseFamily(undefined)`
is always `'Aggregate'`, never `'LineToNeutral'`).

Normalize terminology `L-N per-phase` -> `per-phase L-N` in JSDoc
and wire comments per R8 Lane C NIT (single term per concept).

One regression test added exercising the mixed-family case:
INLET per-phase L-N templates plus an OUTLET aggregate template
with the flag enabled must emit two aggregate samples (INLET
synthesized, OUTLET preserved), not one.

Test invariant `fail: 0, skipped: 6` preserved.

Spec citation: OCPP 2.0.1 DMD
`docs/ocpp2/Appendices_CSV_v1.4/dm_components_vars.csv:213`:
`SampledDataCtrlr;RegisterValuesWithoutPhases;;no;boolean` -
"will only report the total energy over all phases".

* refactor(meter-values): use Map.groupBy in RegisterValuesWithoutPhases helper (issue #1936 k)

Redesign `applyRegisterValuesWithoutPhases` per session-established
criteria for elegance, harmonization, and TS/JS state-of-the-art:

- Replace the imperative two-Maps-plus-double-loop dance
  (`perPhaseLNByFamily` + `otherByFamily`) with a single
  `Map.groupBy(bucket, templateFamilyKey)` call, harmonizing with the
  sibling `groupTemplatesByMeasurand` helper in the same file. Both
  now use the same ES2024 grouping primitive for the same conceptual
  operation.
- Extract `templateFamilyKey` to module scope alongside the sibling
  `phaseFamily` and `isLineToNeutralTemplate` helpers, matching the
  file's helper-placement convention (private module-scope pure
  functions above the exported builder).
- Inline the synthesis into a single `surviving.push(...)` call
  (removes the intermediate `synthesized` local).
- Net -4 lines with clearer control flow: per-family filter for
  L-N vs non-L-N, three explicit branches (no L-N to suppress,
  aggregate already configured, synthesize from first L-N),
  followed by a single stable sort.

No behavioral change: the mixed-family regression test at
`CoherentMeterValues.test.ts:1256+` (INLET per-phase L-N +
OUTLET aggregate) still asserts both families emit an aggregate
(INLET synthesized, OUTLET preserved). All prior R1-R8 tests
pass unchanged.

Test invariant `fail: 0, skipped: 6` preserved.

* docs(meter-values): describe per-family synthesis in README and JSDoc (issue #1936 k)

R10 Lane B cross-artifact alignment audit caught 2 MINOR wording drifts
carried over from the pre-R8 (pre-family-partitioning) design that R9
Lane C had implicitly accepted as converged:

1. `README.md:496` Energy.Active.Import.Register bullet described
   synthesis as connector-global ("if no aggregate register template
   is configured, one is synthesized...") without the per-family
   qualifier. HEAD's `applyRegisterValuesWithoutPhases` groups
   templates into identity families keyed by `(context, format,
   location, unit)` and synthesizes an aggregate per family that
   lacks one, so the wording was implying a single global aggregate
   where the code emits one per family.

2. `CoherentMeterValueBuilder.ts` `buildCoherentMeterValue` JSDoc
   `@param registerValuesWithoutPhases` used the same pre-family
   phrasing ("if the connector configures only per-phase L-N
   templates (no aggregate), an aggregate template is synthesized").
   The helper JSDoc was already accurate; only the exported API
   documentation lagged the redesign.

Both descriptions rewritten to state the identity-family key
explicitly and the per-family synthesis semantic. No code change.

Test invariant `fail: 0, skipped: 6` preserved.

4 weeks agorefactor(meter-values): rename Prng.ts to PRNG.ts (issue #1936 c) (#1940)
Jérôme Benoit [Sat, 4 Jul 2026 19:54:07 +0000 (21:54 +0200)] 
refactor(meter-values): rename Prng.ts to PRNG.ts (issue #1936 c) (#1940)

* refactor(meter-values): rename Prng.ts to PRNG.ts (issue #1936 c)

PRNG is the canonical uppercase abbreviation (Pseudo-Random Number
Generator). Uppercase acronyms in file names align with the codebase's
TypeScript PascalCase-for-modules convention (e.g. `OCPPServiceUtils.ts`).
`Prng.ts` was the outlier.

Rename performed via two-step `git mv` to survive case-insensitive
filesystems (macOS APFS default). Updates:
- Path imports (4 sites): `CoherentSampleComputer.ts`, `CoherentSession.ts`,
  `CoherentMeterValues.test.ts`, `PRNG.test.ts`.
- JSDoc `{@link}` module references (3 sites): `CoherentSession.ts` (2),
  `index.ts` (1).
- JSDoc backtick module references (2 sites): `CoherentSession.ts` (1),
  `CoherentMeterValues.test.ts` (1).
- Test suite description string: `describe('Prng', ...)` to
  `describe('PRNG', ...)` in `PRNG.test.ts`.

camelCase identifiers containing "Prng" (`voltagePrng`, `profilePickPrng`,
`socPrng`, `createStreamPrng`) preserved per TypeScript camelCase-for-
variables/functions convention.

Cosmetic; zero runtime change. Test invariant `fail: 0, skipped: 6`
preserved (2895 pass / 2901 total). Build passes.

Closes issue #1936 item (c).

* style(meter-values): normalize prose em-dash to ASCII on PR-C-touched lines (issue #1936 c)

R1 Lane C flagged em-dash (U+2014) in `+` lines of PR-C delta:
- `src/charging-station/meter-values/CoherentSession.ts:71` (JSDoc backtick reference line
  updated by rename)
- `src/charging-station/meter-values/index.ts:23` (JSDoc `{@link}` reference line updated
  by rename)

Both lines were modified by the PR-C rename (`Prng.ts` -> `PRNG.ts` reference updates) and
therefore appear in the branch `+` diff. Session ASCII rule permits only the section sign
(U+00A7) plus pre-existing physics/math notation in JSDoc as non-ASCII exceptions; em-dash
as prose punctuation is not covered.

Fix: replace both `--` (U+2014) with ASCII `-`. Semantic content preserved.

Em-dashes on lines NOT modified by PR-C (CoherentSession.ts:15, index.ts:14/18/21,
PRNG.test.ts x3, CoherentMeterValues.test.ts x1) remain SUB-THRESHOLD per pre-existing
outside line-level delta convention.

Test invariant `fail: 0, skipped: 6` preserved. Build passes.

4 weeks agofix(meter-values): physics refinements - 3-phase-only L-L voltage guard + energyRegis...
Jérôme Benoit [Sat, 4 Jul 2026 18:43:43 +0000 (20:43 +0200)] 
fix(meter-values): physics refinements - 3-phase-only L-L voltage guard + energyRegisterWh docstring (issue #1936 h) (#1941)

* docs(meter-values): document energyRegisterWh transaction-scope divergence (issue #1936 h.2)

`CoherentSample.energyRegisterWh` (in `CoherentSampleComputer.ts`) is
computed from `connectorStatus.transactionEnergyActiveImportRegisterValue
+ deltaEnergyWh` (transaction-scoped) at sample-compute time, but the
emitted `Energy.Active.Import.Register` measurand reads
`connectorStatus.energyActiveImportRegisterValue` (station-scoped) via
`resolvePhasedValue` in `CoherentMeterValueBuilder.ts`. The two counters
diverge whenever the station has non-zero pre-transaction register
history, converging only when the transaction begins on a station whose
register was previously zero.

Documents the divergence on the field's JSDoc so tests and in-process
introspection paths reading `sample.energyRegisterWh` understand the
scope distinction. No runtime change.

* fix(meter-values): tighten L-L voltage guard to 3-phase-only (issue #1936 h.1)

`resolvePhasedValue`'s Line-to-Line voltage branch (in
`CoherentMeterValueBuilder.ts`) previously used `numberOfPhases <= 1` as
the skip guard, which meant that `numberOfPhases === 2` silently emitted
`Math.sqrt(2) * voltageV` -- a physically meaningless value.

2-phase AC is unsupported by contract across the codebase:
`Helpers.getPhaseRotationValue` branches only on `{0, 1, 3}` (AC 1-phase,
AC 3-phase, and DC), and the station-template validator enforces
numberOfPhases in `{0, 1, 3}`. The `<= 1` guard was written assuming the
enum-open case would default to the 3-phase computation; the 2-phase gap
escaped review because no valid station configuration reaches it in
production.

Tightens the guard to `numberOfPhases !== 3` so any non-3-phase
configuration (0, 1, 2, or hypothetical future values) returns
`undefined`, letting the caller log-and-skip rather than emit
`sqrt(2) * V_LN` nonsense. Same behavior for 3-phase (still emits
`sqrt(3) * V_LN`) and for 1-phase (still skipped). Adds a WHY comment
documenting the `V_LL = sqrt(3) * V_LN` contract.

Test invariant `fail: 0, skipped: 6` preserved (2895 pass / 2901 total).

* docs(meter-values): sync resolvePhasedValue JSDoc L-L skip predicate with h.1 (issue #1936 h)

The h.1 guard tightened `numberOfPhases <= 1` to `numberOfPhases !== 3`,
widening the L-L voltage skip predicate from "1-phase only" to "any
non-3-phase count". The `resolvePhasedValue` JSDoc header still
described the pre-h.1 skip case as "`sqrt(phases)` collapses to 1 on
single-phase, in which case L-L has no physical meaning and the
template is skipped" -- factually incomplete post-h.1 (N=0/2/4+ also
skip through the same predicate).

Updates the JSDoc to reflect the current predicate: "L-L is defined
only for balanced 3-phase AC; skipped for any other phase count".
Documentation-atomicity fix per AGENTS.md instruction to "update code,
tests, and documentation atomically".

No runtime change. Test invariant `fail: 0, skipped: 6` preserved
(2925 pass / 2931 total).

* style(meter-values): normalize prose em-dash and ellipsis to ASCII (issue #1936 h)

The session ASCII rule permits only the section sign (U+00A7) plus
pre-existing physics/math notation in JSDoc as non-ASCII exceptions.
It does not permit em-dash (U+2014) or horizontal ellipsis (U+2026)
as prose punctuation. Both symbols pre-existed in the 2 files touched
by PR-H (5 em-dashes in `CoherentMeterValueBuilder.ts`, 14 em-dashes
and 1 ellipsis in `CoherentSampleComputer.ts`) and were classified
through R1-R8 as SUB-THRESHOLD per scope-discipline convention
(pre-existing outside the line-level PR-H delta).

Under explicit user directive to normalize (Option B follow-up),
replaces all 20 prose Unicode occurrences with ASCII (U+2014 to `-`,
U+2026 to `...`) in the 2 touched files. Physics/math notation
(sqrt, multiplication dot, implication arrow, sigma, phi, element-of,
delta, less-or-equal, epsilon) preserved per pre-existing JSDoc
convention.

Test invariant `fail: 0, skipped: 6` preserved (2925 pass / 2931 total).

4 weeks agofix(ocpp20): trigger meterValues path emits MeterValuesRequest per F06.FR.06 (issue...
Jérôme Benoit [Sat, 4 Jul 2026 16:36:19 +0000 (18:36 +0200)] 
fix(ocpp20): trigger meterValues path emits MeterValuesRequest per F06.FR.06 (issue #1936 j) (#1943)

* fix(ocpp20): trigger meterValues path emits MeterValuesRequest per F06.FR.06 (issue #1936 j)

OCPP 2.0.1 F06.FR.06: when a CSMS sends a TriggerMessage with
MessageTrigger.MeterValues, the Charging Station SHALL respond with a
MeterValuesRequest carrying the requested Measurand values.

The current OCPP20IncomingRequestService.handleRequestTriggerMessage
handler for MessageTrigger.MeterValues iterated all connectors with
active transactions and sent TransactionEventRequest(Updated) — a
spec violation introduced by PR #1726. It only fell back to sending
a MeterValuesRequest when no active transactions existed.

Fix: always emit MeterValuesRequest for MessageTrigger.MeterValues.

- Iterate target EVSEs (specific from evse.id, or all if unspecified).
- Per EVSE, build one MeterValue per connector with an active
  transaction using the OCPP 2.0.1 AlignedDataCtrlr.Measurands
  allow-list and TRIGGER ReadingContext (matches F06 semantics for
  aligned/triggered data). Sub-schema MeterValues with empty
  sampledValue arrays are skipped per MeterValueType.sampledValue's
  1..* cardinality.
- Emit one MeterValuesRequest per target EVSE aggregating those
  MeterValues. When an EVSE has no active transactions, emit one
  request with a schema-conforming placeholder so the CSMS observes
  the response.

Test updates:
- Remove the MeterValues entry from the shared parameterized 'fires
  requestHandler once' loop (that assertion baked in the pre-fix
  1-call fallback behavior).
- Add a dedicated F06.FR.06 broadcast test asserting one
  MeterValuesRequest per EVSE (3 EVSEs → 3 requests) — symmetric
  with the existing StatusNotification broadcast test.

Test invariant fail: 0, skipped: 6 preserved.

Closes issue #1936 item (j).

* fix(ocpp20): tag trigger MV placeholder with Trigger context and measurand (issue #1936 j)

The placeholder SampledValue emitted when an EVSE has no active transaction
previously omitted both `context` and `measurand`, silently defaulting to
`Sample.Periodic` and `Energy.Active.Import.Register` per the OCPP 2.0.1
`MeterValuesRequest` schema. TC_F_12_CS (F06.FR.06 certification) asserts
`meterValue[0].sampledValue[0].context = 'Trigger'` on trigger-elicited
MeterValuesRequests, and reporting `Energy.Active.Import.Register = 0`
misleads the CSMS into recording a spurious energy register reset.

Sets `context = OCPP20ReadingContextEnumType.TRIGGER` and
`measurand = OCPP20MeasurandEnumType.POWER_ACTIVE_IMPORT` on the placeholder
so the wire payload is truthful (`0 W` when no charging is in progress)
and consistent with the surrounding trigger emission's declared context.

Extends the broadcast test to assert per-EVSE payload shape (command,
`evseId > 0`, `meterValue` non-empty, `sampledValue[0].context = Trigger`,
request options) mirroring the sibling StatusNotification broadcast test.

Test invariant `fail: 0, skipped: 6` preserved (2925 pass / 2931 total).

* test(ocpp20): use numeric comparator on trigger meterValues broadcast assertion (issue #1936 j)

`Array.prototype.sort()` sorts as strings by default; the coincidence with numeric
ordering only holds for the current fixture's single-digit EVSE ids `{1, 2, 3}`.
If a future fixture bumps `evsesCount` beyond 9, the assertion would silently
reorder (`['1','10','2','3']`) and mask a real bug.

Uses an explicit `(a, b) => a - b` numeric comparator so the sort behavior is
fixture-count-agnostic.

Test invariant `fail: 0, skipped: 6` preserved (2925 pass / 2931 total).

* test(ocpp20): lock trigger meterValues placeholder measurand value (issue #1936 j)

Extends the F06.FR.06 broadcast test to assert
`sampledValue[0].measurand = Power.Active.Import` on the placeholder path,
locking the design tradeoff: the placeholder emits
`Power.Active.Import = 0 W` (truthful when idle) rather than reading the
first entry from `AlignedDataCtrlr.Measurands` (whose default value
`Energy.Active.Import.Register = 0 Wh` would imply a cumulative register
reset).

F06.FR.09 requires "current information only"; a 0-value energy register
is a stronger lie than a 0-value power flow when no transaction is
running. The measurand assertion prevents a future regression that
swaps the placeholder for the schema-default measurand.

Test invariant `fail: 0, skipped: 6` preserved (2925 pass / 2931 total).

* refactor(ocpp20): extract triggerMeterValues + emitEvseMeterValues helpers (issue #1936 j)

Harmonises the OCPP 2.0.1 TriggerMessage(MeterValues) case with the sibling
`triggerStatusNotification` / `triggerAllEvseStatusNotifications` pattern
already established in the same class. The 66-line inline case is replaced
by a single-line delegation, matching the visual rhythm of every other
switch case.

Design refinements enabled by the extraction:

- `emitEvseMeterValues` now consumes the `evseStatus` yielded directly by
  `iterateEvses(true)`, removing a redundant Map lookup on every broadcast
  target (matches the sibling `triggerAllEvseStatusNotifications` idiom).
- The unreachable `evseStatus == null` guard in the broadcast path is
  eliminated; the type is narrowed once at the top of the specific-EVSE
  branch.
- The F06.FR.06 spec-citation comment block is lifted to the helper's
  JSDoc, composing F06.FR.10 (Accepted messages SHALL be sent) and
  F06.FR.11 (evse absent -> broadcast to all EVSEs) references.

Test invariant `fail: 0, skipped: 6` preserved (2916 pass / 2922 total).

* fix(ocpp20): guard buildMeterValue against synchronous throw in trigger path (issue #1936 j)

`buildMeterValue` can throw synchronously - the sibling
`OCPP20ServiceUtils.buildTransactionStartedMeterValues` (lines 165-183)
wraps it in try/catch and logs on failure. `emitEvseMeterValues` was
calling the same builder unguarded; a synchronous throw would have
unwound through the EventEmitter listener without ever reaching the
downstream `.catch(errorHandler)` (which only observes Promise
rejection).

Mirrors the sibling defensive pattern: wraps the per-connector
`buildMeterValue` call in try/catch, logs the error with the module
scope name and forwarded reason, and continues the outer loop so that
one bad connector does not prevent the remaining connectors' meter
values from being aggregated into the outgoing MeterValuesRequest.

Test invariant `fail: 0, skipped: 6` preserved (2925 pass / 2931 total).

4 weeks agochore(deps): update all non-major dependencies (#1948)
renovate[bot] [Sat, 4 Jul 2026 13:11:07 +0000 (15:11 +0200)] 
chore(deps): update all non-major dependencies (#1948)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
4 weeks agofeat(ocpp16): wire signed meter values into trigger + broadcast paths (issue #1936...
Jérôme Benoit [Fri, 3 Jul 2026 21:26:23 +0000 (23:26 +0200)] 
feat(ocpp16): wire signed meter values into trigger + broadcast paths (issue #1936 d) (#1945)

* feat(ocpp16): wire signed meter values into trigger + broadcast paths (issue #1936 d)

OCPP 1.6 Signed Meter Values whitepaper v1.0 requires that when
SampledDataSignReadings + SampledDataSignUpdatedReadings are enabled
and a signing key is configured, a paired SignedData SampledValue
accompanies the Raw SampledValue in every Sample.Periodic-context
MeterValue. The periodic loop (OCPP16ServiceUtils.startUpdatedMeterValues)
already applies POST-HOC signing after buildMeterValue returns, so
periodic MeterValues are signed today.

Two callsites were missing the signing wrapper:
1. OCPP 1.6 TriggerMessage(MeterValues) handler in
   OCPP16IncomingRequestService.ts (both the specific-connectorId
   branch and the broadcast-to-all-connectors branch).
2. Worker broadcast channel in
   ChargingStationWorkerBroadcastChannel.handleMeterValues
   (cross-version handler; signing is 1.6-only per the whitepaper).

Both paths silently emitted unsigned Raw values instead of the
whitepaper-mandated paired SignedData SampledValue when signing was
enabled.

Fix: consolidate the signing block from startUpdatedMeterValues into
a new public static helper OCPP16ServiceUtils.appendSignedUpdatedReadings
that mutates the MeterValue in place. The helper is a no-op when
signing is disabled or the connector's signing config is missing.
Apply the helper at the three missing callsites and refactor
startUpdatedMeterValues to call it too.

Test invariant fail: 0, skipped: 6 preserved (2895 pass / 2901 total).

Spec citation: OCA Application Note 'Signed Meter Values for OCPP 1.6'
v1.0 §3.2.1 (paired SignedData SampledValue).

Closes issue #1936 item (d).

* docs(ocpp16): apply round-1 review fixes on signed MV wire-up (issue #1936 d)

Address cross-validated R1 findings:

- Naming coherence in TriggerMessage broadcast-to-all branch: rename
  loop-local variables from abbreviated 'id'/'cs'/'txId'/'mv' to
  full 'connectorId'/'connectorStatus'/'transactionId'/'meterValue'
  to match the specific-connector sibling branch style. Per AGENTS.md
  naming coherence: two adjacent branches doing the same thing with
  different name styles is the 'synonym creates ambiguity' pattern.

- Rewrite the broadcast channel guard comment to correctly attribute
  the '!isOcpp2' skip. Previous wording claimed 'the whitepaper is OCPP
  1.6 specific', but the whitepaper §4 explicitly covers OCPP 2.x. The
  actual reason for the skip is that OCPP 2.0.x signing is applied
  inline inside buildMeterValue via the versioned dispatcher's signing
  hook, so post-hoc wrapping is the 1.6 pattern only.

- Tighten the appendSignedUpdatedReadings @description to drop
  refactor-history narrative ('Consolidates the signing block used by
  every trigger/broadcast path...'). Replace with operational spec
  citing whitepaper §3.3.6 SampledDataSignUpdatedReadings and the
  mutation contract on connectorStatus.publicKeySentInTransaction
  (per PublicKeyWithSignedMeterValue = OncePerTransaction).

* fix(ocpp16): harden signed MV helper and thread reading context (issue #1936 d)

Enforce the transactionStarted invariant inside `appendSignedUpdatedReadings` so callsites
with looser guards cannot leak signed emissions past `resetConnectorTransactionStatus`.

Accept an optional reading `context` parameter (default `Sample.Periodic`);
`TriggerMessage(MeterValues)` callsites pass `Trigger` per OCPP 1.6 Core Table 30 so the
signed payload's context field matches its emission source.

Downgrade the `readSigningConfigForConnector` disabled-state log from `warn` to `debug`;
the helper is a hot-path probe whose `undefined` return already conveys the disabled state
to callers.

Test invariant `fail: 0, skipped: 6` preserved (2925 pass / 2931 total).

4 weeks agorefactor(charging-station): extract reservation helpers from Helpers.ts (issue #1936...
Jérôme Benoit [Fri, 3 Jul 2026 19:53:26 +0000 (21:53 +0200)] 
refactor(charging-station): extract reservation helpers from Helpers.ts (issue #1936 f Phase 1) (#1946)

* refactor(charging-station): extract reservation helpers from Helpers.ts (issue #1936 f)

Phase 1 of the Helpers.ts file split tracked in issue #1936 item (f).

Helpers.ts is 1389 LOC — 5.5x the 250 LOC ceiling documented in
AGENTS.md's programming skill. This PR extracts the smallest cohesive
block (the 4 reservation helpers) into a dedicated file as an initial
step, proving the barrel-preservation pattern so the remaining slices
can follow in subsequent PRs without breaking any callers.

Extracted symbols into HelpersReservation.ts:
- hasReservationExpired
- hasPendingReservation
- hasPendingReservations
- removeExpiredReservations

Helpers.ts (1389 -> 1336 LOC) keeps the public API via a barrel
re-export block. Every external caller ('import { ... } from
"./Helpers.js"') continues to work unchanged. Now-unused imports
in Helpers.ts (isPast, Reservation, ReservationTerminationReason)
are dropped.

Test invariant fail: 0, skipped: 6 preserved (2925 pass / 2931 total).

Closes issue #1936 item (f) Phase 1 of N.

* [autofix.ci] apply automated fixes

* docs(helpers-reservation): strip historical narrative, align banner, complete JSDoc (issue #1936 f)

Apply round-1 review findings on HelpersReservation.ts:

- Banner alignment: 'Copyright ... 2021-2026' → 'Partial Copyright ...
  2021-2025' to match sibling files in src/charging-station/ that carry
  banners (ChargingStation.ts, Bootstrap.ts, BootstrapStateUtils.ts,
  CoherentMeterValuesManager.ts, AutomaticTransactionGenerator.ts,
  ChargingStationWorker.ts). A repo-wide year bump to 2026 is out of
  scope for this refactor.
- Strip historical narrative from @description per AGENTS.md
  documentation convention ('document current state; exclude historical
  evolution'). Old text narrated the extraction event ('Extracted from
  ./Helpers as the first slice of the issue #1936 (item f) file split'),
  which the commit message + git blame already carry permanently.
  Replace with an operational spec describing the module's current
  responsibility: reservation-state predicates + bulk expired-reservation
  cleanup.
- Add missing @returns on removeExpiredReservations. Every other
  exported helper documents its return; the async cleanup did not.
  New wording also encodes the WHY (Promise.allSettled → never
  rethrows, individual failures logged) that was previously only
  implicit in the body.

* docs(helpers-reservation): harmonize JSDoc voice across predicates (issue #1936 f)

Align the three reservation predicates to a single JSDoc voice:
'hasPendingReservation' and 'hasPendingReservations' were 'Checks
if …'; 'hasReservationExpired' was already 'Determines whether …'
after the R1 doc-fix. Standardize the two predicates to
'Determines whether …' so all three read consistently. Leave
'removeExpiredReservations' as imperative ('Removes every …') since
it is a mutation, not a predicate — different verb class,
appropriate.

Per AGENTS.md 'Naming coherence' — semantically accurate names
across code and documentation, avoid synonyms that create
ambiguity.

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
4 weeks agorefactor(ocpp20): rewrite H9/H10/H11 + C10/C12 audit markers as descriptive comments...
Jérôme Benoit [Fri, 3 Jul 2026 15:51:46 +0000 (17:51 +0200)] 
refactor(ocpp20): rewrite H9/H10/H11 + C10/C12 audit markers as descriptive comments (issue #1936 l) (#1939)

Nine inline comments in OCPP20IncomingRequestService.ts (7) and its
sibling test file OCPP20IncomingRequestService-UpdateFirmware.test.ts
(2) carried internal audit-round finding numbers (H9, H10, H11, C10,
C12) as if they were code identifiers.

Production-file markers (7) originate from commit 3a6e89f634
'fix(ocpp20): remediate all OCPP 2.0.1 audit findings (#1726)' which
predates PR #1935. Test-file markers (2 H11) originate from commit
b50f9e5f 'refactor(tests): separate handler/listener tests and remove
setTimeout hacks' — same audit-round family, later commit.

The audit markers had the shape //<letter+><digits>[:-)] which is
distinct from the legitimate OCPP spec references elsewhere in the
repo (B11 - Reset, F06.FR.06, A02.FR.06, E05.FR.09, L01.FR.30,
C10.FR.04, C12.FR.05, ...) that all follow <UC>.FR.<NN>.

Each rewrite replaces the marker with a WHY-rationale or BDD-style
expectation describing the current-state behavior. See the PR body
table for the full before/after list.

Verification (POSIX character classes; portable across all git-grep -E
builds — the \s shorthand is a PCRE extension not supported by POSIX
ERE and silently returns no match on macOS git):

  git grep -inE '^[[:space:]]*//[[:space:]]*[A-Z]+[0-9]+[]:)-]' -- \
    src/ tests/

returns empty.

Comment-only change. No runtime behavior change.
Test invariant fail: 0, skipped: 6 holds.

Closes issue #1936 item (l).

4 weeks agorefactor(meter-values): split CoherentMeterValuesGenerator into three modules (issue...
Jérôme Benoit [Fri, 3 Jul 2026 14:39:30 +0000 (16:39 +0200)] 
refactor(meter-values): split CoherentMeterValuesGenerator into three modules (issue #1936 item i) (#1938)

* refactor(meter-values): split CoherentMeterValuesGenerator into three modules (issue #1936 item i)

Split the 820 LOC `CoherentMeterValuesGenerator.ts` (introduced by PR
#1935) into three focused modules per issue #1936 item (i):

- `CoherentSampleComputer.ts` (311 LOC): physics chain V→P→I→ΔE→SoC with
  INV-1/2/3 by construction (guard bundle, ramp factor, voltage noise,
  EVSE/EV/capacity clamps, integer-rounded emission, energy accrual,
  monotone SoC saturation), plus `advanceEnergyRegister` which the
  builder invokes once per sample so `meterStop` stays correct even when
  `Energy.Active.Import.Register` is not in the configured MeterValues.
- `CoherentMeterValueBuilder.ts` (364 LOC): OCPP {@link MeterValue}
  assembly — phase family classifier, cross-measurand emit order
  (SoC→Voltage→Power→Current→Energy), within-measurand phase rank
  (no-phase → L1/L1-N → L2/L2-N → L3/L3-N → L1-L2 → L2-L3 → L3-L1 → N),
  kilo/unit divider, template resolution, and the `buildCoherentMeterValue`
  entry point that orchestrates `computeCoherentSample` +
  `advanceEnergyRegister` + per-template emission.
- `CoherentMeterValuesGenerator.ts` (204 LOC, shrunk from 820): session
  lifecycle (`createCoherentSession`, `CreateSessionOptions`), PRNG
  helpers (`createStreamPrng` with the `tx:`-namespaced deriveSeed leg,
  `resolveRootSeed`), the `isCoherentModeActive` type guard, and the
  module-scope WeakMap runtime state (`SessionRuntime`,
  `getSessionRuntime`, `disposeCoherentSessionRuntime`) accessed by the
  computer.

The three modules stack: Generator (WeakMap + PRNG helpers) ← Computer
(imports `getSessionRuntime`, `createStreamPrng`) ← Builder (imports
`computeCoherentSample`, `advanceEnergyRegister`, `ROUNDING_SCALE`,
`CoherentSample`, `ComputeSampleOptions`). No cycles.

The barrel (`meter-values/index.ts`) re-exports `buildCoherentMeterValue`
+ `BuildVersionedSampledValue` from the builder and
`createCoherentSession` / `isCoherentModeActive` / `resolveRootSeed`
from the generator so external consumers
(`CoherentMeterValuesManager`, `OCPPServiceUtils`, `OCPP16ServiceUtils`)
keep their existing imports.

Preserved invariants (per mission constraints):
- `__injectCoherentSession` NODE_ENV production-guard throw
  (unchanged — lives on `CoherentMeterValuesManager`).
- Session-snapshot reads for `voltageOutNominal` / `currentType` /
  `numberOfPhases` (still read from `session.*` inside
  `computeCoherentSample`).
- `tx:` PRNG namespace on the transactionId leg of `createStreamPrng`
  (preserves the XOR self-inverse guard).
- non-finite-`intervalMs` defensive zero-sample guard
  preserved verbatim in `computeCoherentSample`.

Test file imports updated to point at the new module boundaries; no test
logic changes. Test invariant `fail: 0, skipped: 6` holds.

Closes issue #1936 item (i).

* refactor(meter-values): align banner and tighten JSDoc on new split modules (issue #1936 i)

Revert the copyright banner on the three files introduced by the split
(CoherentSampleComputer.ts, CoherentMeterValueBuilder.ts, index.ts) from
'Copyright ... 2021-2026' to 'Partial Copyright ... 2021-2025' to match
the sibling verbatim convention (AutomaticTransactionGenerator.ts,
ChargingStation.ts, CoherentMeterValuesManager.ts, PerformanceStatistics.ts).
Wider repo-wide banner year sweep to 2021-2026 is deferred to a separate
chore commit.

Tighten two hedge-worded JSDoc lines in CoherentSampleComputer.ts:
- 'Sample timestamp in milliseconds (typically Date.now())' now spells
  out 'callers pass Date.now() in production and a test-controlled clock
  in unit tests'.
- Defensive-guard rationale 'a template override or future dynamic
  supply could return 0' tightened to 'a template override may set
  voltageOut to 0'.

CoherentMeterValuesGenerator.ts is intentionally not touched here to
avoid four-way merge collisions with #1940 (Prng rename), #1941
(physics refinements h), #1942 (RegisterValuesWithoutPhases k), and
#1944 (EVSE-level template fallback g). Design-MAJOR findings (inverted
Computer → Generator dependency, widened intra-package exports,
'Generator' semantic misnomer) are documented in the PR body as a
post-stack-merge follow-up.

* refactor(meter-values): align banner to same-folder sibling convention (issue #1936 i)

Drop the 'Partial' prefix on the three files introduced by the split.
Round-1 aligned to parent-folder siblings (ChargingStation.ts,
AutomaticTransactionGenerator.ts, CoherentMeterValuesManager.ts) which
use 'Partial Copyright ... 2021-2025', but same-folder siblings
(EvProfiles.ts, Prng.ts, types.ts) use 'Copyright ... 2021-2025'
without the prefix. Aligning to same-folder convention restores
banner uniformity inside src/charging-station/meter-values/.

CoherentMeterValuesGenerator.ts (2021-2026 outlier) remains untouched
to avoid the four-way merge collision with #1940 / #1941 / #1942 /
#1944 — repo-wide banner sweep is deferred as documented.

* refactor(meter-values): unidirectional DAG for coherent split (issue #1936 i)

Address the design findings raised by post-split review by relocating
runtime state and PRNG plumbing to their responsibility owners:

- Move SessionRuntime, sessionRuntimes, getSessionRuntime to
  CoherentSampleComputer.ts as module-private (they exist only to cache
  the voltage-noise PRNG closure across samples, which is a physics
  concern, not a session-lifecycle concern).
- Move disposeCoherentSessionRuntime to CoherentSampleComputer.ts
  (exported for CoherentMeterValuesManager). Update the manager's
  direct import path accordingly.
- Move createStreamPrng to Prng.ts alongside deriveSeed / mulberry32 /
  hashLabel. It composes those three helpers and is a PRNG primitive,
  not a session helper.
- Rename CoherentMeterValuesGenerator.ts to CoherentSession.ts. After
  the moves the file owns only session identity (createCoherentSession,
  CreateSessionOptions), the strategy-gate type guard
  (isCoherentModeActive), and the root-seed resolver (resolveRootSeed).
  The 'Generator' name no longer describes the file — the entry point
  buildCoherentMeterValue lives in the builder.
- Align banner on the renamed file to same-folder convention (Copyright
  ... 2021-2025, matching EvProfiles / Prng / types).
- Update barrel and test imports.

Result: strictly unidirectional import DAG within meter-values/:

  types  ← { CoherentSession, CoherentSampleComputer,
             CoherentMeterValueBuilder, EvProfiles, Prng }
  Prng   ← { CoherentSession, CoherentSampleComputer }
  EvProfiles ← { CoherentSession, CoherentSampleComputer }
  CoherentSampleComputer ← CoherentMeterValueBuilder

CoherentSession no longer depends on any coherent sibling; Computer no
longer depends on Session (the pre-refactor Computer → Session inversion
is eliminated). Three previously-widened intra-package exports revert to
module-private (SessionRuntime, sessionRuntimes, getSessionRuntime);
ROUNDING_SCALE stays exported (Computer → Builder is the correct
direction). Barrel public surface unchanged.

Wire behavior and physics chain byte-identical — the move preserves
every function body verbatim, only relocating them. Test invariant
fail:0, skipped:6 preserved (2925 pass / 2931 total).

The four open PRs touching CoherentMeterValuesGenerator.ts (#1940 c,
#1941 h, #1942 k, #1944 g) will need manual rebase resolution on top
of this refactor — accepted trade-off per the maintainer's directive
to do the design work now rather than defer.

* docs(meter-values): apply round-3 review fixes (issue #1936 i)

- Retarget the stale '{@link ./CoherentMeterValuesGenerator}' reference
  in CoherentSampleComputer.ts @file JSDoc to describe the current state
  (physics chain + session-runtime WeakMap + disposeCoherentSessionRuntime
  teardown hook) instead of the pre-split historical evolution, per the
  AGENTS.md 'document current state; exclude historical evolution' rule.
- Restore the getSessionRuntime JSDoc dropped during the Generator →
  Computer move. Documents the load-bearing invariant that the
  voltage-noise PRNG closure must be cached across samples so the
  stream advances rather than restarting from the same seed each draw.
- Rename CoherentMeterValuesGenerator.test.ts to CoherentMeterValues.test.ts.
  Update @file header and describe() label to match the new subject —
  the test file now exercises CoherentSession, CoherentSampleComputer,
  CoherentMeterValueBuilder, and Prng; no single 'generator' module
  remains post-refactor.

* docs(meter-values): apply round-4 review fixes (issue #1936 i)

- Fix dangling '{@link ../../CoherentMeterValuesManager...}' at
  CoherentMeterValueBuilder.ts:287. From src/charging-station/meter-values/,
  '../../' resolves to src/ (target does not exist there); correct
  path is '../CoherentMeterValuesManager' (src/charging-station/). This
  matches the fixes already applied to the sibling occurrences in
  CoherentSampleComputer.ts:9 and CoherentSession.ts:37; Builder was
  missed during the round-3 sweep.
- Add 'satisfies readonly MeterValueMeasurand[]' to MEASURAND_EMIT_ORDER
  in CoherentMeterValueBuilder.ts. Matches the 'as const satisfies
  Record<...>' idiom already used by PHASE_FAMILY and PHASE_RANK.
  Gates against a typo-introduced non-measurand value at compile time.
- Expand the label-style '// EV acceptance from the curve at running
  SoC.' comment in CoherentSampleComputer.ts to document the load-bearing
  physics invariant: the taper MUST interpolate at session.socPercent
  (live state advanced by advanceEnergyRegister) rather than a constant
  or the initial SoC. Without this, a future maintainer could replace
  the live read with a constant, breaking the taper (P would not
  decrease as SoC rises, over-charging past the capacity clamp,
  violating INV-3).

* docs(meter-values): correct socPercent mutator reference in taper comment (issue #1936 i)

The round-4 physics comment at CoherentSampleComputer.ts:299 stated
that session.socPercent is advanced 'by the previous
advanceEnergyRegister tick', but advanceEnergyRegister (line 180)
only mutates connectorStatus energy registers — it does not touch
session.socPercent. The actual mutation happens at the end of
computeCoherentSample itself (line 354).

Retarget the comment's mutation-site reference to computeCoherentSample
and inline-reference the assignment below. This preserves the
load-bearing invariant that round-4 documented (taper must interpolate
at live SoC, not initial) while pointing the future maintainer to the
correct code location — a maintainer following the wrong pointer to
advanceEnergyRegister would find no session mutation and might
delete the 'stale' comment or replace the live read with a constant,
silently breaking the taper (P would not decrease as SoC rises →
over-charging past capacity clamp → violation of INV-3).

* docs(coherent): strip historical narrative from JSDoc + test prose (issue #1936 i)

Apply AGENTS.md 'document current state; exclude historical evolution'
across the coherent-mode surface (both this PR's meter-values files
and the CoherentMeterValuesManager landed in PR #1937):

- CoherentMeterValuesManager.ts:@file — 'Extracted from ChargingStation'
  narrated origin. Rewrite to 'Sits alongside ChargingStation' — same
  design rationale (keeping the strictly opt-in coherent surface off
  the main class body) without the historical framing.
- CoherentMeterValuesManager.ts:injectSession JSDoc — 'mirroring the
  seam previously exposed on ChargingStation' claimed the seam was
  no longer on ChargingStation, but __injectCoherentSession is still
  a live delegator on ChargingStation (points at manager.injectSession).
  Drop 'previously' — the seam mirrors the ChargingStation delegator.
- CoherentMeterValueBuilder.ts:@file — 'Extracted from the original
  single-file generator as part of the issue #1936 (item i) file
  split to keep each module under the 250 LOC ceiling' was pure
  historical narrative; drop the sentence entirely.
- CoherentMeterValueBuilder.ts:resolveTemplates JSDoc — 'tracked as a
  follow-up in issue #1936' was TODO-adjacent but redundant with
  git-blame trail. Keep the current-state limitation description
  (EVSE-level template inheritance needs getEvseStatus on the port).
- meter-values/index.ts:@description — 'Module layout after the
  issue #1936 (item i) split:' → 'Module layout:'.
- CoherentMeterValues.test.ts:@file — 'Exercises the split modules
  together' → 'Cross-module tests spanning'.
- CoherentMeterValues.test.ts:797 assertion — '(moved to module-scope
  runtime state)' → '(owned by module-scope runtime state in
  CoherentSampleComputer)'. Same invariant, current-state phrasing.

4 weeks agochore(deps): update all non-major dependencies (#1947)
renovate[bot] [Fri, 3 Jul 2026 12:52:19 +0000 (14:52 +0200)] 
chore(deps): update all non-major dependencies (#1947)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
4 weeks agorefactor(meter-values): extract CoherentMeterValuesManager, shrink ICoherentContext...
Jérôme Benoit [Thu, 2 Jul 2026 23:38:05 +0000 (01:38 +0200)] 
refactor(meter-values): extract CoherentMeterValuesManager, shrink ICoherentContext, split OCPP16 begin helper (issue #1936 items a, b, e) (#1937)

* refactor(meter-values): extract CoherentMeterValuesManager (issue #1936)

Extract per-station coherent MeterValues lifecycle owner from
ChargingStation into a dedicated singleton-per-station manager,
mirroring the multiton pattern of AutomaticTransactionGenerator /
IdTagsCache / SharedLRUCache (keyed by stationInfo.hashId).

The manager owns the EV profile file, the active-session Map, and
the create/destroy/inject lifecycle. ChargingStation keeps the four
public methods as thin delegators for API stability — external OCPP
handler call sites, the test helper mock, and existing tests are
unchanged.

Behavior is preserved:
- Sessions are still created only when coherentMeterValues=true AND
  a valid EV profile file loads.
- The __injectCoherentSession NODE_ENV production guard is preserved
  on the manager side (BaseError throw).
- Session runtime state is disposed at every reset/stop/disconnect
  path via destroySession, and at station stop/delete via dispose /
  deleteInstance.

Closes issue #1936 item (a).

* refactor(meter-values): thread coherent session, shrink ICoherentContext (issue #1936)

Drop `getCoherentSession` from `ICoherentContext`: the port now
exposes only what the physics chain needs to query about the station
itself. Session lookup moves to the caller (the strategy gate), which
looks up via `ChargingStation.getCoherentSession` — the A1 delegator
that routes through `CoherentMeterValuesManager` in production and
through the test-mock's own Map in tests.

Signature changes:
- `isCoherentModeActive(session): session is CoherentSession` — pure
  type guard replacing the two-tier (stationInfo + session-lookup)
  predicate. The stationInfo gate is implied by session existence
  (sessions are only created via the manager when
  `coherentMeterValues=true`).
- `buildCoherentMeterValue(context, session, ...)` — takes the session
  directly. The internal `context.getCoherentSession` lookup and the
  "missing session" warning branch collapse to a single connector-lookup
  guard.

Strategy gate call sites in `OCPPServiceUtils.buildMeterValue` and
`OCPP16ServiceUtils.buildTransactionBeginMeterValue` are threaded
accordingly.

Behavior preserved: sessions are still only routed to the coherent path
when they exist; random/fixed path is untouched. Test suite invariant
`fail: 0, skipped: 6` holds.

Closes issue #1936 item (b).

* refactor(ocpp16): extract buildCoherentTransactionBeginMeterValue (issue #1936)

Split OCPP16ServiceUtils.buildTransactionBeginMeterValue's dual
responsibility. The coherent short-circuit (route through
buildMeterValue with the vendor StartTxnSampledData override) moves to
a named private static helper; the outer function keeps only the
random/fixed default path plus the strategy gate.

The strategy gate in OCPP 1.6 now lives at a single well-labeled
boundary in this file, mirroring the pattern established by
OCPPServiceUtils.buildMeterValue for the periodic MeterValues path.

Behavior is unchanged: same measurand-key resolution, same
buildMeterValue re-dispatch, same TRANSACTION_BEGIN context.

Closes issue #1936 item (e).

* refactor(meter-values): add peekInstance to avoid phantom manager allocation (issue #1936)

PR-A round-1 Oracle review (lanes A + D converged on this MAJOR
finding): the strategy gate in `OCPPServiceUtils.buildMeterValue`
reaches `ChargingStation.getCoherentSession` unconditionally on every
MeterValue tick, and the delegator was routed through
`CoherentMeterValuesManager.getInstance` — a lazy-create factory.
Result: every station that ever emits a MeterValue allocated a
`CoherentMeterValuesManager` + Map, regardless of the `coherentMeterValues`
opt-in flag. This contradicted the file-header design contract that
'stations with the option off never allocate a manager'.

Fix: add `CoherentMeterValuesManager.peekInstance(cs)` — a lookup-only
sibling of `getInstance` that never constructs. Route the four
read/destroy/dispose sites through `peekInstance`:
- `ChargingStation.createCoherentSession` (opt-in station's manager
  is warmed up at initialize; non-opt-in stations return `undefined`
  without allocating).
- `ChargingStation.destroyCoherentSession`
- `ChargingStation.getCoherentSession` (the strategy-gate hot path).
- `ChargingStation.stop()` finally-block dispose.

Keep `getInstance` (create-if-missing) at:
- `ChargingStation.__injectCoherentSession` — production-guard
  BaseError throw must remain reachable if this test seam is
  accidentally called in prod.
- `ChargingStation.initialize` — the opt-in eager warm-up that surfaces
  EV-profile-file warnings at startup rather than at first transaction.

Also tighten `getInstance` JSDoc to state precisely that `undefined` is
returned iff `stationInfo.hashId` is not yet resolved (the sole failure
mode), and cross-reference `peekInstance` for read paths.

Behavior for opt-in stations is unchanged (warm-up allocates the same
manager, subsequent reads find it in the cache). Non-opt-in stations
no longer allocate. Test invariant `fail: 0, skipped: 6` holds.

Closes issue #1936 item (a) sub-finding from round-1 review.

* docs(meter-values): tighten getInstance/peekInstance JSDoc post round-2 review (issue #1936)

PR-A round-2 Oracle review (lanes A + B converged on JSDoc precision):

- Lane A: `getInstance` JSDoc still listed `createSession` as a caller,
  but the round-1 fix routed `createSession` through `peekInstance`.
  A future reader following the JSDoc could reintroduce the phantom-
  allocation bug at the exact site it was just patched.
- Lane B: JSDoc labeled `destroySession` and `stop()` dispose as
  "read-only paths". Both are actually mutations (Map.delete +
  PRNG-closure disposal). Reword to "paths that must not allocate a
  manager on behalf of non-opt-in stations".
- Lane A NIT: the eager-init invariant is load-bearing — if
  `ChargingStation.initialize` stops warming up the manager for opt-in
  stations, `createCoherentSession` silently no-ops. The invariant was
  implicit; make it explicit in `peekInstance` JSDoc.

Docs-only. No runtime change. Test invariant `fail: 0, skipped: 6` holds.

* fix(meter-values): propagate template reload to coherent manager (issue #1936)

The template file watcher and reset() path already flush sharedLRUCache,
idTagsCache, and OCPPAuthServiceFactory before calling initialize(). The
coherent manager was left behind: getInstance returned the cached
manager for the unchanged hashId, whose evProfiles were snapshotted at
first-construction, so runtime mutations of evProfilesFile or its file
contents did not take effect until process restart.

Wire reloadEvProfiles() into every initialize() invocation so template
mutations propagate. Symmetrically drop the manager entirely when the
coherentMeterValues opt-in flips true to false so cached sessions and
stale profile data do not leak across the config change.

* refactor(meter-values): gate injectSession, harmonize banner and JSDoc (issue #1936)

injectSession test seam now honors the stationInfo.coherentMeterValues
opt-in gate. Pre-refactor, isCoherentModeActive checked the flag on
every tick; post-refactor it trusts session existence. Without the gate,
a test could inject a session on a non-opt-in station and activate the
coherent wire path — contradicting the type-guard precondition.
Production is already protected by the NODE_ENV production throw.

Align copyright banner to the sibling verbatim form
('Partial Copyright Jerome Benoit. 2021-2025').

Harmonize JSDoc: peekInstance MUST-use list now includes createSession
(mirrors getInstance JSDoc); reloadEvProfiles fail-soft description
covers non-opt-in stations and any error; 'cache miss' prose replaced
with 'instances-map miss' to match the field name.

* refactor(meter-values): preserve in-flight coherent sessions across opt-in flag flip (issue #1936)

The previous initialize() logic dropped the CoherentMeterValuesManager
via deleteInstance() when coherentMeterValues flipped true to false.
That immediately disposed every in-flight coherent session — and, in
the template file watcher flow where initialize() runs before
stopAutomaticTransactionGenerator(), that produced mixed-provenance
TransactionEvent frames on OCPP 2.0.x: the Begin frame was coherent,
the Update/End frames after the flip fell through the strategy gate
to random path, and the CSMS saw a discontinuous transaction.

Remove the else branch. New sessions are already blocked by the
coherentMeterValues gate in createSession; existing in-flight
sessions drain naturally via destroyCoherentSession on transaction
end. Provenance is preserved for transactions started before the
flag flip, and the reloadEvProfiles propagation on the true branch
(the round-1 MAJOR fix) is unaffected.

* docs(meter-values): calibrate injectSession JSDoc for mock-helper reality (issue #1936)

The prior JSDoc claimed the opt-in guard means isCoherentModeActive
cannot be tricked into activating the coherent wire path by
injecting on a non-opt-in station. That is true only for the
production-backed injection path (through the real
CoherentMeterValuesManager.injectSession). Tests that mock
ChargingStation write to their own session store bypassing this
seam entirely, so the mock is responsible for enforcing its own
opt-in invariant where relevant. Calibrate the JSDoc scope
accordingly.

4 weeks agofeat(meter-values): coherent MeterValues generator (issue #40) (#1935)
Jérôme Benoit [Thu, 2 Jul 2026 17:26:49 +0000 (19:26 +0200)] 
feat(meter-values): coherent MeterValues generator (issue #40) (#1935)

* feat(meter-values): coherent MeterValues generator (issue #40)

Implements physics-coherent MeterValues (V->P->I->dE->SoC) gated by template
flag coherentMeterValues. Session lifecycle on ChargingStation with txId
snapshotted before resetConnectorStatus. Strategy gate after versioned
sampled-value dispatcher, before legacy random measurand generation.
Deterministic Mulberry32 PRNG with per-label stream splitting. New module
under src/charging-station/meter-values/. Golden invariants harness green.

Refs: #40

* test(meter-values): regression tests for Phase 4 findings (issue #40)

RED phase for M1..M4 findings from /tmp/issue-40/review-consolidated.md:
- M1: voltage PRNG must advance state across samples
- M2: deltaEnergyWh must be clamped to remaining battery capacity at 100% SoC
- M3-OCPP16: coherent session destroyed even if station stops during postTransactionDelay
- M3-OCPP20: same for OCPP 2.0 sibling path
- M4: stopEnergyWh assertion strengthened to remove self-reference tautology

Currently 4 tests fail (expected RED); M4 rewrite passes (strengthening only).

* fix(meter-values): address Phase 4 review findings (issue #40)

- M1: cache voltage PRNG on CoherentSession (was reconstructed each
  sample, producing a stalled seed sequence). PRNG state now advances
  across samples as documented.
- M2: clamp powerW to remaining battery capacity so a sample crossing
  100 % SoC cannot over-charge the register. Everything downstream
  (I, ΔE, register) is recomputed from clamped power, preserving
  INV-1 and INV-3.
- M3-OCPP16 / M3-OCPP20: destroy the coherent session BEFORE awaiting
  postTransactionDelay so an intervening station stop cannot leak the
  session. destroyCoherentSession is idempotent so the post-sleep
  path remains valid.

Regression tests (M1..M4): pass 23/23.
Full suite: pass 2908/0/6 skipped.

* fix(coherent-meter-values): floor reportedPowerW to remaining capacity + tighten M4 register cross-check

Phase 6 verification findings addressed:
- N1 (gpt-5.5 HIGH, opus MED): capacity-clamp fallback risked INV-1 breach.
  Investigation: CURRENT_ROUNDING_SCALE=2 keeps V*I within <=0.1 W of
  reportedPowerW on realistic mains, well under INV-1 tolerance (+/-1 W).
  Simplified: floor reportedPowerW to maxPowerFromCapacityW after utility
  recompute (absorbs float drift without touching currentA).
- V1-M4 secondary (sonnet 'partial'): assertion now reads MV[last] (was
  MV[2], tautological). Independent Sigma(P*dt) primary check unchanged.

* [autofix.ci] apply automated fixes

* fix(meter-values): address PR #1935 review findings (issue #40)

Consolidated fixes for all 30 findings from the multi-agent code review of
PR #1935. Preserves the coherent MeterValues feature scope; adds OCPP 2.0
end-to-end wiring; hardens correctness and idiomaticity.

Blocking (2):
- B1 fix INV-1 breach in capacity-clamp branch: derive current as exact
  fraction (P / (V·phases)), round at emit, then derive emitted P from
  rounded V·I·phases. Prior integer-amp rounding could inflate V·I·phases
  above the capacity-clamped power by up to V·phases·0.5 W. New AC 3-phase
  and DC regression tests lock the invariant.
- B2 wire OCPP 2.0.1 support: add createCoherentSession call in
  OCPP20ResponseService.handleResponseTransactionEvent (Started case),
  mirroring OCPP 1.6. New 5-scenario integration test covering Accepted,
  implicit Accept, rejected idToken, force-override, and non-Started events.

High (4):
- H1 clear coherentSessions in ChargingStation.stop() finally; dispose
  runtime state per session before delete.
- H2 README: add three template-parameter rows (coherentMeterValues,
  evProfilesFile, randomSeed) and a new 'EV profile file format' subsection
  documenting the ev-profiles-template.json schema.
- H3 strip process residue: remove /tmp/issue-40/* references (3 files),
  'Phase 2 merged finding #N' and 'Fix Phase 4 M-N' markers (8+ locations);
  replace with technical rationale.
- H4 label INV-1/INV-2/INV-3 in the class-level JSDoc using the
  PR-body-canonical numbering (INV-1=V·I=P, INV-2=SoC monotone,
  INV-3=ΔE=P·Δt). Remove undefined INV-4 reference.

Medium (13):
- M1 DRY resolveRootSeed via hashLabel (byte-equivalent, test-locked).
- M2 move voltagePrng runtime state off CoherentSession to a module-scope
  WeakMap keyed on session identity (no cross-station coupling); add
  disposeCoherentSessionRuntime wired into destroyCoherentSession + stop().
- M3 collapse five identical rounding scales into a single ROUNDING_SCALE.
- M4 add explanatory comment on the boundary 'as MeterValue' cast
  (OCPP16/20 SampledValue.context enums structurally diverge).
- M5 correct Prng.ts JSDoc: 'SplitMix32-derived' -> 'Mulberry32 + FNV-1a'.
- M6 remove 'byte-identical' over-claims; use 'reproducible' / 'identical'.
- M7 defensive early-return zero-sample when intervalMs <= 0 to prevent
  NaN contamination when SoC has already saturated.
- M8 trim meter-values/index.ts barrel from 22 to 11 externally-consumed
  symbols.
- M9 mark 7 immutable CoherentSession fields readonly.
- M10 add ChargingStation.injectCoherentSession() public method and use it
  in 3 test sites, replacing 'station as unknown as { coherentSessions }'
  private-field injection.
- M11 fix 'transaction id' -> 'transactionId' in Prng.ts comment.
- M12 replace global Date.now monkey-patch with per-iteration
  mock.method(Date, 'now', ...) from node:test.
- M13 remove dead chargingProfileLimitW parameter
  (getConnectorMaximumAvailablePower already folds in charging profiles).

Low / Nit:
- C4 clear-on-stop (covered under H1).
- C5 XOR-commutativity in deriveSeed: deferred with explanatory comment
  (birthday bound ~2^16 well beyond simulator scale; a non-commutative mix
  would desync existing golden tests).
- D-7 rename prng.ts -> Prng.ts (and prng.test.ts -> Prng.test.ts) to
  match repo PascalCase filename convention.
- D-8 move getEvProfilesFile from EvProfiles.ts to Helpers.ts next to
  getIdTagsFile.
- D-9 fix resolveTemplates JSDoc: remove false 'mirrors EVSE lookup' claim.
- D-11 CoherentMeterValuesDefaults now exposes all tunable constants.
- D-18 use AvailabilityType.Operative enum instead of string cast.
- I1 auto-resolved by B1 (ROUNDING_SCALE=2 now semantically meaningful
  for current).
- T2 use getErrorMessage() (repo convention) instead of (error as Error).
- S2 simplify templatesFor test helper — remove 'as unknown as { unit }'
  casts.
- S5 consolidate duplicate BuildVersionedSampledValueFn type into the
  canonical BuildVersionedSampledValue exported from meter-values.

Verification:
- pnpm lint      0 errors, 0 warnings
- pnpm typecheck 0 errors
- pnpm test      2918 pass / 0 fail / 6 skipped
- New regression tests locking B1 (INV-1 clamp AC3+DC), M7 (NaN guard),
  M2 (session hygiene + WeakMap dispose), M1 (hashLabel equivalence),
  B2 (OCPP 2.0 session wiring, 5 scenarios).

Closes #40

* fix(meter-values): address PR #1935 round-2 review findings (issue #40)

Consolidated fixes for all Medium+Low+Nit findings from the second
multi-agent review round of PR #1935. Two Blocking findings (S1 OCPP 1.6
signed-meter-values wrapper, S2 begin MeterValue routing) — S2 implemented,
S1 deferred to a follow-up (post-hoc signing in startUpdatedMeterValues
already covers the OCPP 1.6 periodic path; the remaining gap is only
TriggerMessage/broadcast callsites, best served by a dedicated PR with a
proper signing-key test fixture).

## Blocking (1 of 2 addressed; other deferred)

- **S2 (implemented)** — `Transaction.Started` / `Transaction.Begin`
  MeterValue is now generated by the coherent path. Made
  `ChargingStation.createCoherentSession` idempotent so early
  request-builder creation is safe; reordered OCPP 2.0 flows
  (`OCPP20ServiceUtils.startTransactionOnConnector`,
  `OCPP20IncomingRequestService` RequestStartTransaction handler) to
  create the session BEFORE `buildTransactionStartedMeterValues`;
  reordered OCPP 1.6 flow (`OCPP16ResponseService.handleResponseStartTransaction`)
  and added a coherent-mode branch to
  `OCPP16ServiceUtils.buildTransactionBeginMeterValue` that routes
  through `buildMeterValue` when a session is live.

- **S1 (deferred)** — OCPP 1.6 signed-meter-values wrapper for the
  coherent path. Rationale: `startUpdatedMeterValues` in
  `OCPP16ServiceUtils` applies post-hoc signing after `buildMeterValue`
  returns, so the OCPP 1.6 periodic coherent path IS signed today. The
  actual gap is only the TriggerMessage / worker-broadcast callsites
  where signing is skipped. Fixing it cleanly requires a signing-key
  test fixture that is best set up in a dedicated PR.

## Medium (8)

- **M-01/M-02/M-03/M-07 combined defensive-guard block**
  (`computeCoherentSample`): early-return zero-sample when `intervalMs ≤ 0`
  (existing), `batteryCapacityWh ≤ 0` or non-finite (M-01), or
  `voltageOut ≤ 0` or non-finite (M-02). `rampUpDurationMs` guard now
  requires `> 0 && Number.isFinite(...)` (M-03). Prevents NaN poisoning
  and INV-1/INV-3 incoherence across the four sources.
- **M-04** — Reverted the proposed Zod refinement for sorted `chargingCurve`
  after design analysis showed `loadEvProfilesFile`'s in-place sort is
  the authoritative defense and the refinement would break the loader's
  "accepts unsorted, sorts in place" contract. Documented rationale in
  `EvProfileSchema` JSDoc.
- **M-06** — Renamed `ChargingStation.injectCoherentSession` →
  `__injectCoherentSession` (dunder-prefix test-seam convention);
  tightened mock parameter type from `unknown` to `CoherentSession`;
  updated the 3 test call sites.
- **M-07** — Fixed the determinism claim in README.md and PR body:
  `interval` is a physics parameter, not a PRNG seed input. New prose
  makes this explicit.
- **M-08** — Coherent path now respects OCPP 2.0.1 `SampledDataCtrlr.TxUpdatedMeasurands`
  / `TxEndedMeasurands` / `TxStartedMeasurands` and OCPP 1.6
  `MeterValuesSampledData`. Added `resolveEnabledMeasurands` helper in
  `OCPPServiceUtils.ts` and threaded a `ReadonlySet<MeterValueMeasurand>`
  allow-list into `buildCoherentMeterValue`. Governs J02.FR.11 / E02.FR.09
  / E06.FR.11.

## Deferred to follow-up issue (3)

- **M-05** — Extract `CoherentMeterValuesManager.getInstance(chargingStation)`.
  Sibling per-station concerns (AutomaticTransactionGenerator, IdTagsCache,
  SharedLRUCache) use the multiton pattern; the coherent module currently
  keeps state on `ChargingStation`. Not a correctness issue; architectural
  refactor best done standalone.
- **N-03** — Blocked on M-05 (semantic circularity in `ICoherentContext.getCoherentSession`
  cleaned up naturally when the manager owns the session store).
- **N-04** — `Prng.ts` filename kept as-is; renaming to `PRNG.ts` on a
  case-insensitive macOS FS would require a second two-step rename with
  no functional benefit.

## Low/Nit (10)

- **N-01** — Dropped `disposeCoherentSessionRuntime` from the
  `meter-values/index.ts` barrel; direct sub-module import in
  `ChargingStation.ts` (barrel exposes only externally-consumed symbols).
- **N-02** — Rephrased the `Fix B1:` process-comment in
  `CoherentMeterValuesGenerator.ts` to invariant-only technical rationale
  (H3 miss from the previous round).
- **N-05** — Renamed voltage locals in `computeCoherentSample`:
  `voltageNominal`/`voltageV`/`roundedVoltage` →
  `nominalV`/`sampledV`/`roundedV` (fewer names for the same quantity).
- **N-06** — Added defensive `destroyCoherentSession` in the OCPP 2.0
  `handleResponseTransactionEvent` `Ended` branch when connectorId is
  unknown (symmetric with OCPP 1.6 defensive destroy in
  `handleResponseStopTransaction`).
- **N-07** — Tightened `deriveSeed` JSDoc with quantified birthday
  bound: expected collisions ≈ N²/2^33; at simulator scale (≤ 5×10⁴
  pairs) ≈ 0.3 — negligible.
- **N-08** — Tightened AC1/AC3/DC test tolerances from ≤ 1/3/1 W to
  ≤ 0.01 W (post-Option D tight bound is 0.005 W).
- **N-09** — Uniform `getErrorMessage(error)` in EvProfiles.ts
  ZodError branch (was direct `error.message`, inconsistent with the
  else branch).
- **N-10** — Removed dead exports `CoherentMeterValuesDefaults` and
  `mixSeed` (grep-verified zero external usages).
- **N-11 F-04** — `@file` tag "MeterValue generator" (singular) →
  "MeterValues generator" (plural OCPP term).
- **N-11 F-07** — README schema example id aligned with the actual
  `ev-profiles-template.json` (`compact-ev-40kwh` → `city-ev-40kWh`).
- **N-11 F-08** — README documents the `initialSocPercentMin` ≤
  `initialSocPercentMax` swap-and-warn behavior.

## Verification

- `pnpm typecheck` — 0 errors
- `pnpm lint` — 0 errors, 0 warnings
- `pnpm test` — 2878 pass / 0 fail / 6 skipped

Refs #40

* fix(meter-values): address PR #1935 round-3 review findings (issue #40)

Consolidated fixes for all findings from the third multi-agent review round.
No blocking findings remain; two content-lane blocks (barrel count, README
example) plus 4 cross-validated HIGH nits addressed.

## HIGH (4)

- **H1** — Correct false JSDoc claim at `ChargingStation.ts:319`. The
  `__injectCoherentSession` docstring stated the `__` prefix was enforced
  by a `no-restricted-syntax` ESLint rule; no such rule exists. Reworded
  to "convention only — not currently enforced by a lint rule".
- **H2** — Trim 4 dead type re-exports from `src/charging-station/index.ts`
  (`ChargingCurvePoint`, `EvProfile`, `EvProfilesFile`, `ICoherentContext`).
  Grep-verified zero external consumers. `CoherentSession` kept (used by
  4 test files). Barrel is now honest about its "only externally-consumed
  symbols" contract.
- **H3** — Sync README `city-ev-40kWh` schema example to
  `src/assets/ev-profiles-template.json` exactly: `maxPowerW` 7400→11000,
  `weight` 1.0→3, `initialSocPercentMin` 10→15, `initialSocPercentMax`
  80→55, 3-point curve → 4-point taper. First-time readers now see the
  same values in both docs.
- **H4** — Validate CSV entries in `resolveEnabledMeasurands`
  (`OCPPServiceUtils.ts`). `Object.values(MeterValueMeasurand)`-membership
  guard drops unknown entries and logs a per-station-debounced warning.
  Prevents silent typo tolerance (e.g. `Voltege` in config CSV) while
  accepting every OCPP-defined measurand (unlike the narrower
  `isMeasurandSupported` allowlist).

## MED (3)

- **M1** — Thread `TxUpdatedMeasurands` into `buildMeterValue` at both
  OCPP 2.0 sites that previously bypassed it: `OCPP20ServiceUtils.ts`
  `startUpdatedMeterValues` (periodic path) and
  `OCPP20IncomingRequestService.ts` `TriggerMessage(MeterValues)` handler.
  Closes the J02.FR.11 spec gap where CSMS-configured measurand filters
  were ignored on the periodic Updated path.
- **M2** — Debug-only sortedness assertion in `interpolateChargingCurve`
  (`EvProfiles.ts`). Production path (`loadEvProfilesFile`) sorts in
  place; the assertion catches misuse from the `__inject*` test seam
  (unsorted curves silently returned the tail power-fraction — a hidden
  test footgun). `process.env.NODE_ENV !== 'production'` gate keeps
  runtime cost at zero in production.
- **M3** — Session-leak safety net at 2 OCPP 2.0 request-time create sites:
  `OCPP20ServiceUtils.ts` `startTransactionOnConnector` wraps
  `sendTransactionEvent` in `try/catch`; `OCPP20IncomingRequestService.ts`
  `RequestStartTransaction` handler augments the existing `.catch` chain.
  Both call `destroyCoherentSession(txId)` before re-throwing/logging,
  symmetric with the OCPP 1.6 `resetConnectorOnStartTransactionError`
  pattern. Prevents session Map growth on WebSocket-send rejection.

## Content + Low/Nit

- **P-01** — Extend the defensive guard block in `computeCoherentSample`
  with `!Number.isFinite(options.nowMs)`. Matches the pattern for the
  other 4 guarded inputs. Not reachable in production (`Date.now()`
  always finite) but closes the test-injection hole.
- **P-02** — Document `rampUpDurationMs = Number.EPSILON` equivalence
  with 0 (both collapse to immediate full-power). Comment only.
- **D-05** — Add `logger.warn` to the OCPP 2.0
  `handleResponseTransactionEvent` Ended-with-unknown-connector branch,
  mirroring the OCPP 1.6 diagnostic parity at
  `OCPP16ResponseService.ts:534-536`.
- **N-03** — Expand `ComputeSampleOptions.voltageNoise` inline comment
  into a full JSDoc with `@remarks` and `@defaultValue`.
- **N-04** — Document the WHY for `createCoherentSession` idempotency
  (OCPP 2.0.x has two call sites per transaction from S2 fix).
- **N-05** — Document the `<quantity><Unit>` naming convention on
  `CoherentSample` (all fields follow `currentA`/`powerW`/`voltageV`).

## Deferred with rationale (documented decisions)

- **A6** — `buildTransactionBeginMeterValue` reads
  `connectorStatus.transactionId` internally rather than accepting an
  explicit param. Single call site, safe mutation ordering already
  documented; adding a param would add noise for zero safety gain.
- **D-03** — `buildTransactionBeginMeterValue` dual-responsibility
  (coherent short-circuit + legacy path) tracked in follow-up issue
  #1936 as new M-06 item.

## Verification

- `pnpm typecheck` — 0 errors
- `pnpm lint` — 0 errors, 0 warnings
- `pnpm test` — 2899 pass / 0 fail / 6 skipped

Refs #40

* fix(meter-values): address PR #1935 round-4 review findings (issue #40)

Consolidated fixes for all findings from the fourth multi-agent review round
(5 parallel reviewers: Oracle elegance/TS · Oracle math/physics · general
OCPP-spec-via-qmd · explore harmonization · general content/terminology).
Design phase cross-validated 5 architecturally-loaded decisions via Oracle.

## HIGH (6)

- **H1 [R4C]** — Thread `OCPP20ReadingContextEnumType.TRIGGER` into the
  `TriggerMessage(MeterValues)` handler at `OCPP20IncomingRequestService.ts:611`.
  Emitted `SampledValue.context` now correctly labels values taken in
  response to `TriggerMessage` per OCPP 2.0.1 §3.66 ReadingContextEnumType;
  previously defaulted to `Sample.Periodic`.

- **H2 [R4C]** — Presence-aware `resolveEnabledMeasurands` semantics in
  `OCPPServiceUtils.ts`. Discriminates key-absent (defaults to
  `{Energy.Active.Import.Register}` for ergonomic parity) from key-present
  (honors the CSV verbatim, including empty ⇒ empty allow-list per
  OCPP 2.0.1 J02.FR.11). Removes the unconditional force-add at line 1115.

- **H3 [R4C]** — Per-phase measurand emission in `buildCoherentMeterValue`.
  Restructured to iterate all templates per measurand (was: first-matching
  only) with phase-aware value resolution:
  `Voltage L-N ⇒ V`; `L-L ⇒ √phases × V`.
  `Power L-N ⇒ P/phases`; L-L unsupported (log-and-skip).
  `Current line ⇒ sample.currentA` (balanced 3-φ Y).
  `SoC / Energy.Active.Import.Register` phase-qualified templates rejected.
  Emit order: `SoC → V → P → I → Energy` across measurands; within a
  measurand: `no-phase → L1/L1-N → L2/L2-N → L3/L3-N → L1-L2 → L2-L3 →
  L3-L1 → N → other`. Closes legacy-parity regression for 3-phase stations
  with phase-qualified templates.

- **H4 [R4D]** — `throw new BaseError(...)` in `EvProfiles.ts`
  `interpolateChargingCurve` (was: bare `Error`), aligning with AGENTS.md
  TypeScript conventions on typed errors.

- **H5 [R4D]** — `assert.ok(cs != null, 'Expected connector 1 to exist')`
  in `OCPP20ServiceUtils-PostTransactionDelay.test.ts` (was: `throw new
  Error`), matching the pattern used elsewhere in the test suite.

- **H6 [R4E]** — README §EV profile file format now documents the
  connector-level-only `MeterValues` template resolution scope under
  coherent mode (EVSE-level inheritance not applied; tracked as follow-up).

## MED (12)

- **M1 [R4A]** — `computeCoherentSample` now takes the resolved
  `session` as a parameter (was: fetched twice via `context.getCoherentSession`).
  Removes 2 unreachable defensive branches (\~25 LOC) and tightens the
  contract into a pure physics function.

- **M2 [R4C]** — OCPP 1.6 `buildTransactionBeginMeterValue` threads
  vendor param `StartTxnSampledData` when configured, falling back to
  `MeterValuesSampledData` when absent — per the OCPP 1.6 Signed Meter
  Values whitepaper.

- **M3 [R4D]** — Move `MS_PER_HOUR` and `UNIT_DIVIDER_KILO` to
  `Constants` class (`src/utils/Constants.ts`). Was duplicated as
  file-scope constants in `OCPPServiceUtils.ts` and
  `CoherentMeterValuesGenerator.ts`. All references now use
  `Constants.MS_PER_HOUR` / `Constants.UNIT_DIVIDER_KILO`.

- **M4 [R4D]** — Move `VOLTAGE_NOISE_PERCENT` to
  `Constants.DEFAULT_COHERENT_VOLTAGE_NOISE_PERCENT`. Tunables belong in
  the canonical defaults map per AGENTS.md.

- **M5 [R4D]** — Move `DEFAULT_RAMP_UP_DURATION_MS` to
  `Constants.DEFAULT_COHERENT_RAMP_UP_DURATION_MS`. Same rationale.

- **M6 [R4D]** — Merge same-specifier `import type` statements in
  `types.ts` and `EvProfiles.ts` (was: 2 statements each for the same
  module). Aligns with the project's `import/no-duplicates` /
  `verbatimModuleSyntax` convention.

- **M8/M11 [R4D]** — `describe('Prng', ...)` and
  `describe('StrategyDispatch', ...)` renames to match the
  module-name-only convention in `tests/TEST_STYLE_GUIDE.md`.

- **M9 [R4E]** — README precision: "emitted measurand list" no longer
  claims `ΔE` is emitted (it is an internal per-sample intermediate);
  INV-1 spelled out per current-type (`P = V·I·phases` for AC, `P = V·I`
  for DC).

- **M10 [R4E]** — Rewrite forward-looking comment at
  `OCPP16ServiceUtils.ts:152-158` to describe the current-branch semantics
  (`StartTxnSampledData` override with fallback to
  `MeterValuesSampledData`) instead of the deferred S1 signing wiring.

## LOW/NIT batch

- **R4A-LOW-01** — `advanceEnergyRegister` rewritten with
  `Math.max(0, ... ?? 0)` — one-expression clamp-and-init (was: two-step
  nullish-then-negative guard, ~14 LOC → 6 LOC).

- **R4A-LOW-02** — 3 near-identical `CoherentSample` zero-literal returns
  in `computeCoherentSample` collapsed to a single `buildZeroSample`
  helper. Two of the three defensive branches also removed by M1.

- **R4A-NIT-01** — `findTemplate` replaced by `groupTemplatesByMeasurand`
  (needed for H3 per-phase iteration anyway); legacy `for..of` scan is now
  gone.

- **R4B-LOW-01/02** — INV-1/INV-3 docstring precision: emit-time rounding
  bound stated as "`ROUNDING_SCALE` half-width (\~0.005 W scalar)"; INV-3
  divergence bound quantified (\~0.12 Wh over 24 h at 1 Hz).

- **R4B-LOW-05** — `EvProfileSchema` JSDoc documents monotone-non-increasing
  `powerFraction` as a caller responsibility (not schema-enforced;
  real curves are flat through CC before tapering).

- **R4B-NIT-06** — AC `numberOfPhases <= 0` now triggers the zero-sample
  defensive branch (was: silently produced `P = 0` via
  `divisor = V × 0 = 0`).

- **R4B-NIT-07** — `interpolateChargingCurve` JSDoc documents the
  closed-closed interval semantic (left segment wins at interior nodes).

- **R4D-LOW-06/07/08** — `StationHelpers.ts` mock: `coherentSessions`,
  `createCoherentSession`, `getCoherentSession` return types tightened
  from `unknown` to `CoherentSession | undefined` (and
  `Map<number | string, CoherentSession>`).

- **R4D-NIT-03/04/05/06** — `meter-values/index.ts` barrel comment now
  explicitly enumerates the intentionally-omitted internal exports
  (`computeCoherentSample`, `advanceEnergyRegister`, `createStreamPrng`,
  `disposeCoherentSessionRuntime`, option-bag types, EvProfiles helpers,
  Prng primitives, Zod schemas).

- **R4E-LOW-05** — `Prng.ts` header no longer claims a specific LOC
  bound ("kept intentionally small").

- **R4E-LOW-06** — Expanded JSDoc on `ComputeSampleOptions` and
  `CreateSessionOptions` interfaces (per-field semantics, units, defaults).

## Deferred to follow-up #1936 (documented rationale)

- **M7 [R4D]** — `StationHelpers.ts` (954 LOC) modular split. Structural
  refactor with 30+ test file consumers; TODO comment added at the top of
  the file linking to #1936. Detailed split sketch documented in the
  design phase.

- **H6 code-side** — Extend `ICoherentContext` with `getEvseStatus` to
  restore EVSE-level template inheritance. Round-4 lands docs-only.

- **R4B-LOW-03/04** — Physics model design notes (`cos φ = 1` assumption,
  linear-vs-S-curve ramp shape). Not blockers.

## Non-findings (verified compliant)

- OCPP 2.0.1 `TxUpdatedInterval` correctly wired for periodic scheduling.
- `Transaction.Begin` / `Transaction.End` enum literals correct.
- Coherent path does not spoof `signedMeterValue`; signing gate intact.
- Coherent path does not bleed into `AlignedDataCtrlr` /
  `ClockAlignedDataInterval` handling.
- Existing hyphenated test file names (`OCPP16-CoherentMeterValues.test.ts`
  etc.) match the `ChargingStation-Configuration.test.ts` sub-domain
  precedent — not a divergence.

## Verification

- `pnpm typecheck` — 0 errors
- `pnpm lint` — 0 errors, 0 warnings
- `pnpm test` — 2920 pass / 0 fail / 6 skipped

Refs #40

* fix(meter-values): OCPP-spec-strict emit + per-phase register + polish (issue #40)

## OCPP 2.0.1 spec-strict emit shape

- `TransactionEvent(Updated)` no longer serializes an empty `meterValue`
  wrapper when `TxUpdatedMeasurands` resolves to an empty allow-list.
  Periodic `startUpdatedMeterValues` short-circuits; TriggerMessage
  (MeterValues) active-tx branch sends the event without the `meterValue`
  field. Matches OCPP 2.0.1 J02.FR.11 ("no meter values are sent") and
  fixes the JSON-schema `minItems: 1` violation on empty sampledValue.

## OCPP 1.6 whitepaper composition (StartTxnSampledData × beginEndMeterValues)

- `OCPP16ResponseService` gates the extra `MeterValues.req` sends at
  both the start (`beginEndMeterValues`) and end (`outOfOrderEndMeterValues`)
  branches on `isNotEmptyArray(sampledValue)`. Composes cleanly with the
  Signed Meter Values whitepaper §3.3.4 rule (`StartTxnSampledData`
  present + non-empty) and the legacy `MeterValuesSampledData` fallback:
  both empty ⇒ no send, avoiding a schema-suspect empty sampledValue
  wrapper.

## Coherent path — per-phase physics polish

- **Per-phase Energy.Active.Import.Register**: L-N templates now emit
  `register / phases` (balanced 3-φ Y contribution per phase); L-L and
  `N` phases skipped with a warn. OCPP 2.0.1
  `SampledDataCtrlr.RegisterValuesWithoutPhases` config-driven suppression
  not consulted (documented follow-up).
- **Neutral phase**: new `Neutral` phase-family classifier distinguishes
  bare `N` from `LineToNeutral` (`L1`/`L1-N`/etc.). `N`-qualified
  Voltage and Current templates emit 0 (balanced 3-φ Y: I_N = 0 by
  phasor sum, V_N = 0 by reference-node definition).
- **L-L voltage on 1-phase**: log-and-skip. Previously the `√1 × V`
  degeneracy silently emitted nominal V under an L-L label.
- **L-N power on phases≤0**: log-and-skip (previously fell through to
  aggregate power under a per-phase label — unreachable in practice due
  to the outer defensive guard but semantically inconsistent).
- **Register clamp sync**: `preRegisterWh` in `computeCoherentSample`
  applies `Math.max(0, ... ?? 0)` matching `advanceEnergyRegister`;
  reported `sample.energyRegisterWh` and post-advance persisted state
  now agree even under corrupted negative register state.

## Elegance + TS state-of-the-art

- Extract `resolveUnitDivider(measurand, unit)` +
  `KILO_UNIT_BY_MEASURAND` at module scope, removing the inline
  `(A && B) || (C && D)` boolean at the emit site.
- `PHASE_RANK` converted from `ReadonlyMap` to object literal with
  `as const satisfies Record<MeterValuePhase, number>` for
  compile-time exhaustiveness; `phaseRank`'s runtime fallback dropped.
- `resolvePhasedValue` returns exact fractions (no internal rounding);
  rounding happens once at the emit site after unit-divider scaling.
  Removes double-rounding asymmetry between aggregate and per-phase paths.
- `groupTemplatesByMeasurand` uses `Map.groupBy` (Node 22+) in place of
  the hand-rolled loop; per-bucket phase sort preserved.
- Merged split `import type { ConnectorStatus }` with sibling type-import
  from the same specifier.

## Documentation

- README §Phase-qualified measurands: "nominal V" → "sampled V"
  everywhere (the coherent path emits `sample.voltageV`, i.e.
  post-noise rounded voltage — not the station's nominal); documents
  `N` phase behavior and per-phase Energy register emission.
- `resolvePhasedValue` JSDoc updated to match implementation.
- `ComputeSampleOptions` and `CreateSessionOptions` JSDoc converted
  from bullet-list style to inline per-field JSDoc (matching
  `CoherentSession` and `ICoherentContext` in `types.ts`).
- `types.ts` file header rewritten to state the current interface
  contract instead of deferred tooling-contingency rationale.
- `Constants` docstrings for coherent tunables /
  `MS_PER_HOUR` / `UNIT_DIVIDER_KILO` shortened to one-liners matching
  the concision of sibling entries.
- `CoherentMeterValuesGenerator.ts` header carries a follow-up TODO
  for the 250-LOC ceiling exceedance (split scope documented).
- Explanatory comment on the module-scope `warnedInvalidMeasurands`
  WeakMap in `OCPPServiceUtils.ts` distinguishing its GC-keyed intent
  from a true singleton.
- Internal-only comment on `VersionedSampledValueDispatch` interface.

## Tests

- New per-phase emission integration test: 3-phase AC session with
  L-N/L-L voltage, aggregate + per-phase Power, L1/N Current, and L-N
  Energy templates; asserts V_L1_N=230, V_L1_L2≈√3·230, Σ per-phase P
  ≈ aggregate P, I_N=0, L-L voltage skipped on 1-phase, L-N energy
  register emitted as `register / phases`.
- `describe('OCPP 1.6 coherent MeterValues integration')` renamed to
  `describe('OCPP16CoherentMeterValues')` (module-name convention).
- OCPP 1.6 integration test replaces its local `MS_PER_HOUR = 3_600_000`
  literal with a `Constants.MS_PER_HOUR` alias (canonical source).
- Removed leaked internal-review annotations from 9 describe/it labels
  (`(regression: ...)` suffixes) — these violated documentation
  timeliness by embedding non-behavioral session metadata into the
  behavioral test tree.

## Verification

- `pnpm typecheck` — 0 errors
- `pnpm lint` — 0 errors, 0 warnings
- `pnpm test` — 2923 pass / 0 fail / 6 skipped

Refs #40

* fix(meter-values): periodic-event omission + Energy L-N alignment + polish (issue #40)

## OCPP 2.0.1 spec-strict emit shape

- Periodic `TransactionEvent(Updated)` no longer drops the whole message
  when `TxUpdatedMeasurands` resolves empty. Sends the event with the
  `meterValue` field omitted, matching the R5 fix already in place on the
  `TriggerMessage(MeterValues)` active-tx branch. Preserves the periodic
  heartbeat cadence at the OCPP level while honoring J02.FR.11
  ("no meter values are sent") — the message is still delivered, only
  the sampled-value payload is elided.

## Coherent path — phase-degeneracy symmetry

- `Energy.Active.Import.Register` L-N templates on `phases <= 0` now
  log-and-skip (return `undefined`), matching `Power.Active.Import`'s
  behavior for the same misconfiguration. Previously the register branch
  fell through to emit the aggregate under a per-phase template label.

## Elegance + TS state-of-the-art

- `resolvePhasedValue` signature narrowed from
  `(measurand, template, sample, phases, connectorStatus)` to
  `(measurand, phase, sample, phases, connectorStatus)`. The function
  only reads `template.phase`; taking the whole template widened the
  coupling. Caller passes `template.phase` at the loop head.
- `resolveUnitDivider` gains a JSDoc block (sole helper in the file that
  previously lacked one) and reorders the guard to
  `unit != null && KILO_UNIT_BY_MEASURAND.get(measurand) === unit` for
  intent-first reading ("only kilo-divide when a unit is present").
- `LEGACY_EMIT_ORDER` renamed to `MEASURAND_EMIT_ORDER`. The constant
  is the canonical emission order of the coherent path, not a
  compatibility shim; the JSDoc note preserves the "mirrors the legacy
  path" provenance.
- `buildZeroSample` now owns all rounding for the zero-sample path.
  Callers pass raw `socPercent` / `voltageV`; the helper applies
  `roundTo` internally. Rounding-responsibility is unified in one place.

## Documentation

- INV-1 docstring documents both the aggregate residual (≤ 0.005 W) and
  the per-phase L-N residual (≤ 0.01 W = 2 × `ROUNDING_SCALE` half-width),
  quantifying the two-step rounding bound (aggregate emit + per-phase
  division).
- `resolvePhasedValue` JSDoc documents the per-phase Energy register
  conservation bound: Σ across all L-N templates equals the aggregate
  register within emit-unit rounding granularity (Wh: ≤ phases · 0.005 Wh;
  kWh: ≤ phases · 5 Wh).
- `VersionedSampledValueDispatch.signingState` JSDoc: unresolvable
  `{@link buildVersionedSampledValue}` replaced with plain prose
  (the reference is a local closure, not an exported symbol).
- `resolveEnabledMeasurands` `@param measurandsKey` prose clarified:
  `undefined` (or omitted) defaults to `StandardParametersKey.`
  `MeterValuesSampledData` for OCPP 1.6; returns `undefined` (no filter)
  for all other versions.
- `meter-values/index.ts` barrel comment simplified. Dropped the
  enumerated intentionally-omitted list (drifts with internals); kept
  the intent sentence only.
- `ChargingStation.createCoherentSession` JSDoc: dropped the
  request-builder/response-handler call-site narrative; kept the API
  contract ("idempotent; returns `undefined` when coherent mode is
  disabled or no valid EV profile file is loaded").

## README

- `§Template resolution scope`: dropped the internal
  `getSampledValueTemplate` helper name and the backlog-tracking sentence.
  Documents current behavior only (connector-level templates, no
  EVSE-level inheritance under coherent mode).
- `§Phase-qualified measurands`: dropped the "tracked as a follow-up"
  tail on the `RegisterValuesWithoutPhases` note.
- Charging station template configuration table: `three phased` →
  `three-phase`, `line to line voltage` → `line-to-line voltage`
  (hyphenation consistent with the rest of the docs).

## Tests

- Added mandatory `afterEach(() => standardCleanup())` block to
  `CoherentMeterValuesGenerator.test.ts`, `EvProfiles.test.ts`, and
  `Prng.test.ts` per `tests/TEST_STYLE_GUIDE.md` §Mandatory Cleanup.
- `describe('OCPP20ServiceUtils — PostTransactionDelay')` renamed to
  `describe('OCPP20ServiceUtilsPostTransactionDelay')` — module-name
  concatenated form matching the existing coherent-test naming.
- `describe('B2 - OCPP 2.0.1 TransactionEvent Started → coherent session
  wiring')` renamed to `describe('OCPP20ResponseServiceCoherentSession')`
  — module-name-only form; the descriptive/spec-prefix suffix was
  redundant with the inner `it` labels.

## Verification

- `pnpm typecheck` — 0 errors
- `pnpm lint` — 0 errors, 0 warnings
- `pnpm test` — 2920 pass / 0 fail / 6 skipped

Refs #40

* [autofix.ci] apply automated fixes

* refactor(meter-values): phase-family Record + shared test interval constant (issue #40)

## phaseFamily as an exhaustive Record

- Convert `phaseFamily`'s switch statement to a `PHASE_FAMILY` object
  literal with `as const satisfies Record<MeterValuePhase, ...>`,
  matching the `PHASE_RANK` pattern already established for phase
  ranking. Adding a new `MeterValuePhase` enum value now fails compile
  at the table declaration until the value is classified — the same
  compile-time gate PHASE_RANK enforces.
- The `phaseFamily` function becomes a one-liner:
  `phase == null ? 'Aggregate' : PHASE_FAMILY[phase]`.
  `Aggregate` remains the sentinel for the undefined-phase case.
- Removes the defensive `default: return 'Aggregate'` branch that
  silently absorbed unclassified enum values.

## phaseRank inlined

- Drop the `phaseRank` helper (used at exactly one call site) and
  inline the null-check + `PHASE_RANK` lookup directly into the
  `groupTemplatesByMeasurand` bucket-sort comparator. The
  `PHASE_RANK` table is now the visible source of truth at the sort
  site, matching the `PHASE_FAMILY` pattern above.

## Shared TEST_METER_VALUES_INTERVAL_MS constant

- Add `TEST_METER_VALUES_INTERVAL_MS = 30_000` to
  `tests/charging-station/ChargingStationTestConstants.ts` as the
  canonical test interval.
- Replace 27 inline `30_000` / `30000` literals across
  `CoherentMeterValuesGenerator.test.ts` (20 sites),
  `OCPP16-CoherentMeterValues.test.ts` (4 sites, including the local
  `const INTERVAL_MS = 30_000` that was itself a duplicate), and
  `StrategyDispatch.test.ts` (3 sites). Preserves value; consolidates
  the semantic "meter-values sample interval used across coherent
  tests" into one importable constant.

## Verification

- `pnpm typecheck` — 0 errors
- `pnpm lint` — 0 errors, 0 warnings
- `pnpm test` — 2923 pass / 0 fail / 6 skipped

Refs #40

* [autofix.ci] apply automated fixes

* chore(meter-values): strip internal-review markers + JSDoc/docstring polish (issue #40)

## Internal-review-marker cleanup

Removed 19 in-code review-round prefixes (`M1:`, `M2:`, `M3:`, `M4:`,
`B1`, `B2`, `Regression M4:`, `Regression B2:`) from test assertion
messages, JSDoc `@description`, and inline comments across 5 test files:
- `CoherentMeterValuesGenerator.test.ts` — 7 sites (voltage-noise,
  capacity-clamp, INV-1 residual assertions).
- `OCPP16-CoherentMeterValues.test.ts` — 6 sites (stop-energy
  reconstruction and coherent-session-leak assertions + comment).
- `OCPP20ServiceUtils-PostTransactionDelay.test.ts` — 1 site
  (coherent-session-leak assertion).
- `OCPP20ResponseService-CoherentSession.test.ts` — 4 sites (`@description`
  + three eventType/idToken assertions).
- `OCPP16ResponseService-ForceTxOnInvalid.test.ts` — 1 comment block
  ("exercises the regression:" process language → behavioral "exercises
  the pre-Start guard").

Assertion messages now describe the invariant being checked in plain
English without carrying review-round bookkeeping. This is a follow-up to
the earlier cleanup that stripped `(regression: XX)` suffixes from
`describe`/`it` labels but missed codes inside assertion messages and
`@description` blocks.

## JSDoc / doc hygiene

- `PHASE_FAMILY` (`CoherentMeterValuesGenerator.ts`) — deleted the stale
  first JSDoc block that documented the pre-refactor `phaseFamily`
  function; kept the block that documents the current Record with
  compile-time exhaustiveness gate.
- `VersionedSampledValueDispatch` (`OCPPServiceUtils.ts`) — converted
  the `//` block comment above the interface to a proper `/** */` JSDoc
  block matching the sibling-interface convention. Body documents the
  internal-only dispatch-bag role with `@link` cross-references.
- `warnedInvalidMeasurands` and `KNOWN_MEASURANDS` (`OCPPServiceUtils.ts`)
  — moved above the `resolveEnabledMeasurands` JSDoc block so the JSDoc
  is now adjacent to the function it documents (was previously separated
  by the two `const` declarations, breaking the visual JSDoc→function
  association).
- `template.unit as MeterValueUnit | undefined` cast (`CoherentMeter`
  `ValuesGenerator.ts`) — added an intent comment documenting the OCPP
  2.0 open-string-branch narrowing at the Map-lookup boundary and the
  fallback semantics (non-enum strings return `undefined` from the Map
  and fall through to `divider = 1`).
- `MEASURAND_EMIT_ORDER` (`CoherentMeterValuesGenerator.ts`) — dropped
  the explicit `readonly MeterValueMeasurand[]` annotation; kept `as const`
  for a narrow tuple type matching the sibling `PHASE_FAMILY`/`PHASE_RANK`
  pattern (immutability via const-assertion, coherent style across the
  three module-scope constants).
- `ChargingStation.__injectCoherentSession` JSDoc — dropped the
  "not currently enforced by a lint rule" sentence (build-tool trivia,
  not API behavior).

## Test constants

- `TEST_METER_VALUES_INTERVAL_MS` rationale documented on the
  `Timer Intervals` block header (`_MS` intervals are intentionally
  half of `Constants.DEFAULT_*_INTERVAL_MS` for bounded test wall-clock).
- `ChargingStationTestConstants.ts` file header rewritten from the
  narrative-prose + `@see` form to standard `@file` / `@description`
  JSDoc matching the rest of the test suite.

## Verification

- `pnpm typecheck` — 0 errors
- `pnpm lint` — 0 errors, 0 warnings
- `pnpm test` — 2923 pass / 0 fail / 6 skipped

Refs #40

* docs(meter-values): tighten OCPP schema-cardinality citation + test tolerance message (issue #40)

- OCPP20ServiceUtils.ts / OCPP20IncomingRequestService.ts: replace loose
  J02.FR.11 shorthand on the meterValue-field-omission branches with the
  actual grounding — MeterValueType.sampledValue cardinality 1..* combined
  with TransactionEventRequest.meterValue cardinality 0..* — so a reader
  sees why the empty case yields {} rather than { meterValue: [] }.
- CoherentMeterValuesGenerator.test.ts: capacity-clamp failure messages
  now report 2 × ROUNDING_SCALE (0.01 W) to match the assertion tolerance
  (the two rounding steps: aggregate P = V·I·phases plus post-clamp
  re-round each contribute one half-width).

* refactor(meter-values): use session snapshot for voltage, currentType, numberOfPhases (issue #40)

computeCoherentSample now reads voltageOutNominal, currentType, and
numberOfPhases from the session object (immutable snapshot captured at
createCoherentSession) instead of the live context. The three fields
were already declared readonly on CoherentSession but only two were
consulted at runtime, leaving voltageOutNominal dead. Using the snapshot
makes the physics chain deterministic against hypothetical mid-session
station-config drift and reduces per-sample context calls from three to
zero (getConnectorMaximumAvailablePower stays live — the EVSE cap is a
genuinely dynamic quantity that can change during a transaction).

* fix(meter-values): namespace transactionId in createStreamPrng to prevent XOR self-inverse (issue #40)

createStreamPrng chained two XOR-based deriveSeed calls, so
String(transactionId) === label collapsed the derived stream seed back
to the root seed (deriveSeed(deriveSeed(r, X), X) === r). Trigger
required transactionId to match a literal stream label
('VOLTAGE_NOISE', 'POWER_NOISE', 'PROFILE_PICK', 'INITIAL_SOC') —
extremely unlikely with OCPP-numeric transactionIds but reachable via
test seams. Namespace the transactionId leg with a 'tx:' prefix that
labels never carry so the two hash inputs cannot algebraically cancel.
Prng.ts deriveSeed docstring updated to reference the namespacing in
createStreamPrng and drop the 'Known limitation (deferred)' language.

* chore(charging-station): guard __injectCoherentSession against production use (issue #40)

The public __injectCoherentSession method is a test seam whose only
prior protection was the '__' naming convention. Add a runtime guard
that throws a typed BaseError when NODE_ENV === 'production', mirroring
the pattern used by interpolateChargingCurve in EvProfiles.ts for the
unsorted-curve defensive check. Tests run with NODE_ENV unset or 'test'
so the guard is transparent to them; accidental production use now
fails loudly instead of silently corrupting the session store.

* fix(meter-values): tighten defensive guard against non-finite intervalMs (issue #40)

The computeCoherentSample defensive guard applied Number.isFinite to
batteryCapacityWh, nominalV, and nowMs but only tested intervalMs <= 0.
Since NaN <= 0 and +Infinity <= 0 both evaluate to false in JS, either
pathological value escaped the guard, propagated through
maxPowerFromCapacityW = remainingWh * MS_PER_HOUR / intervalMs, and
permanently poisoned session.socPercent to NaN (NaN + x === NaN for
every subsequent sample). Reachability was low in practice — legitimate
callers pass integer intervals and convertToInt throws on NaN — but a
non-compliant CSMS setting MeterValueSampleInterval to a pathological
value via ChangeConfiguration could reach the coherent path.

Add !Number.isFinite(options.intervalMs) to the OR chain, mirroring the
sibling checks. Docstring bullet rewritten to reflect the tightened
coverage. Two new test cases lock the behavior: intervalMs=NaN and
intervalMs=+Infinity both short-circuit to a zero sample with session
state intact, matching the existing intervalMs=0 semantics. The
describe block is renamed from 'intervalMs=0 defensive guard' to
'intervalMs defensive guard' to reflect the broader coverage.

* chore(helpers): drop caller-coordination note on resetConnectorStatus (issue #40)

The 'NOTE: transactionId is deleted here. Callers that need to identify
the just-stopped transaction MUST snapshot ... BEFORE invoking this
function.' comment described a caller-side coordination concern rather
than the WHY of the delete. Every caller that needs the transactionId
already snapshots it before the reset; the note added no local WHY and
duplicated a responsibility that belongs in the caller, not in
resetConnectorStatus.

* docs(meter-values): drop 'legacy' framing for the random/fixed simulation mode (issue #40)

The random/fixed measurand generation is not deprecated — it is one of
two peer simulation modes exposed by the simulator, selected via the
coherentMeterValues station flag. The word 'legacy' throughout the
newly-introduced comments, JSDoc, README, and test descriptions
inaccurately implied a deprecation path.

Neutralize 13 sites across 6 files: 'legacy' either dropped where it
was purely decorative (e.g. 'mirroring the legacy X path' → 'mirroring
the X path') or replaced with 'random/fixed' / 'default' where the
descriptor was substantive. No code changes, no test-logic changes,
purely prose.

* [autofix.ci] apply automated fixes

* docs(meter-values): neutralize residual 'Legacy path' inline comment (issue #40)

F12-A retitled the enclosing it() from 'via the legacy path' to 'via
the random/fixed path' but the inline WHY comment two lines below still
said 'Legacy path' — same author, same commit, same test block.
Missed by the case-sensitive verification grep used at F12-A landing;
this closes the neutralization loop.

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
4 weeks agochore(deps): lock file maintenance (#1927)
renovate[bot] [Wed, 1 Jul 2026 21:41:59 +0000 (23:41 +0200)] 
chore(deps): lock file maintenance (#1927)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
4 weeks agofix(deps): update all non-major dependencies (#1933)
renovate[bot] [Wed, 1 Jul 2026 12:23:44 +0000 (14:23 +0200)] 
fix(deps): update all non-major dependencies (#1933)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
4 weeks agochore(deps): update dependency prettier to ^3.8.5 (#1932)
renovate[bot] [Tue, 30 Jun 2026 00:44:22 +0000 (00:44 +0000)] 
chore(deps): update dependency prettier to ^3.8.5 (#1932)

* chore(deps): update dependency prettier to ^3.8.5

* [autofix.ci] apply automated fixes

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
4 weeks agochore: release main (#1928) cli@v4.10.1 ocpp-server@v4.10.1 simulator@v4.10.1 ui-common@v4.10.1 v4.10 web@v4.10.1
Jérôme Benoit [Mon, 29 Jun 2026 22:44:06 +0000 (00:44 +0200)] 
chore: release main (#1928)

* chore: release main

* [autofix.ci] apply automated fixes

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
4 weeks agorefactor: project-wide audit of helper/util usage in src/ + OCPP 2.0.1 §F01.FR.13...
Jérôme Benoit [Mon, 29 Jun 2026 22:34:53 +0000 (00:34 +0200)] 
refactor: project-wide audit of helper/util usage in src/ + OCPP 2.0.1 §F01.FR.13 spec fix (#1931)

* refactor(utils): replace bare structuredClone with clone helper

Adopts the existing `clone` helper from `src/utils/Utils.ts` at 7 sites in 4
files where `structuredClone` was called directly. `clone` is a thin wrapper
around `structuredClone` (`return structuredClone(object)`); the swap is
behaviour-preserving DRY consolidation.

Files touched:
- src/utils/Configuration.ts (1 site)
- src/utils/ConfigurationMigrations.ts (3 sites + import added)
- src/utils/ConfigurationValidation.ts (2 sites + import extended)
- src/charging-station/TemplateValidation.ts (1 site + import extended)

The `as Record<string, unknown>` casts at ConfigurationValidation.ts:103 and
TemplateValidation.ts:62 are preserved on the call sites.

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* refactor(ocpp): replace (error as Error).message with getErrorMessage

The `(error as Error).message` cast is unsound — when the thrown value is
not an `Error` instance, `.message` is `undefined` and the log silently
emits 'undefined', obscuring potentially security-relevant failures (notably
on crypto / signing paths). The `getErrorMessage` helper from
`src/utils/ErrorUtils.ts` handles both `Error` instances and non-`Error`
thrown values via `ensureError`, producing a meaningful string in every
case.

Sites converted (3):
- src/charging-station/ocpp/2.0/OCPP20ServiceUtils.ts:179
  (buildTransactionStartedMeterValues catch block)
- src/charging-station/ocpp/2.0/OCPP20ServiceUtils.ts:1181
  (buildTransactionEndedMeterValues catch block)
- src/charging-station/ocpp/OCPPSignedMeterValueUtils.ts:56
  (deriveSigningMethodFromPublicKeyHex catch block - crypto path)

Imports updated in both files.

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* refactor(ocpp): replace ?.value === 'true' with convertToBoolean

The inline `getConfigurationKey(...)?.value === 'true'` pattern only matches
the literal lowercase string `'true'` and silently treats `'True'`, `'1'`,
`true` (boolean) and any non-string value as false. `convertToBoolean` from
`src/utils/Utils.ts` handles the full set of truthy representations
consistently across the codebase.

Sites converted (7):
- src/charging-station/ocpp/OCPPServiceUtils.ts (3 sites in OCPP 2.0 signed
  meter value generation: SignReadings, SignStartedReadings,
  SignUpdatedReadings flags)
- src/charging-station/ocpp/1.6/OCPP16ServiceUtils.ts (3 sites: isSigningEnabled,
  buildStartedMeterValues and buildUpdatedMeterValues signing guards)
- src/charging-station/Bootstrap.ts (1 site: ENV_SIMULATOR_COLD_START env var)

Imports updated in all three files.

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* refactor: adopt isEmpty/isNotEmptyString helpers for typed collection checks

Consolidates inline emptiness checks on typed strings, arrays and Sets to
use the existing `isEmpty` and `isNotEmptyString` helpers from
`src/utils/Utils.ts`. The helpers cover string, array, Set, Map and plain
object emptiness uniformly; `isNotEmptyString` also trims internally so
`workerScript.trim().length === 0` becomes `!isNotEmptyString(workerScript)`
without behavior change.

isEmpty sites (7):
- src/charging-station/ui-server/UIServerAccessPolicy.ts:230,268,297,407
  (4× .length === 0 on string[] from splitHeaderList / getAllowedHosts)
- src/charging-station/ui-server/UIServerAccessPolicy.ts:508
  (.size === 0 on ReadonlySet<string> trustedProxies)
- src/charging-station/ui-server/AbstractUIServer.ts:1317,1326
  (2× .length === 0 on accessPolicy string[] in UI server constructor warn
  paths)

isNotEmptyString sites (3):
- src/charging-station/ocpp/OCPPSignedMeterValueUtils.ts:76
  (== null || .length === 0 on string | undefined)
- src/worker/WorkerAbstract.ts:29
  (.trim().length === 0 on narrowed string)
- src/charging-station/ui-server/AbstractUIServer.ts:668
  (.length > 0 ternary on rate-limit key)

Imports updated: UIServerAccessPolicy.ts adds isEmpty; OCPPSignedMeterValueUtils.ts
extends to isNotEmptyString; WorkerAbstract.ts adds a fresh utils import.
AbstractUIServer.ts already had both helpers imported.

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* refactor: adopt convertToInt and has helpers for type conversions and property checks

Consolidates inline `Number.parseInt(_, 10)` / `Number(_)` coercions on string
inputs to use `convertToInt`, and `Object.hasOwn` property checks to use
`has` from `src/utils/Utils.ts`. `has` accepts `(property, object)` argument
order (unlike `Object.hasOwn(object, property)`) and adds a null-guard on the
object.

convertToInt sites (3):
- src/charging-station/ui-server/UIServerNet.ts:97
  (`Number.parseInt(port, 10)` on validated digit-string port)
- src/charging-station/ocpp/1.6/OCPP16IncomingRequestService.ts:785
  (`Number(value)` on OCPP config integer-key value)
- src/charging-station/TemplateSchema.ts:268
  (`Number(evseKey)` on Object.entries(template.Evses) record key)

has sites (2):
- src/utils/ConfigurationSchema.ts:210
  (`!Object.hasOwn(value as object, 'accessPolicy')` in Zod refine —
  the `as object` cast is no longer needed since `has` accepts `unknown`)
- src/charging-station/ui-server/UIServerAccessPolicy.ts:355
  (`Object.hasOwn(params, key)` in Forwarded header parameter dedup)

Imports updated in UIServerNet.ts, TemplateSchema.ts, ConfigurationSchema.ts
(fresh utils imports), OCPP16IncomingRequestService.ts (already imported),
UIServerAccessPolicy.ts (extended).

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* refactor: adopt isNotEmptyArray and buildConfigKey for collection checks and log formatting

isNotEmptyArray sites (4):
- src/charging-station/ui-server/AbstractUIServer.ts:1438 (countConnectors
  fallback on data.connectors)
- src/charging-station/ui-server/AbstractUIServer.ts:1451 (iterateConnectors
  guard on data.connectors)
- src/charging-station/ui-server/UIServerAccessPolicy.ts:438 (allowedOrigins
  presence check)
- src/charging-station/ui-server/UIServerAccessPolicy.ts:443 (allowedHosts
  presence check in fallback)

buildConfigKey site (1):
- src/charging-station/ocpp/2.0/OCPP20ServiceUtils.ts:518
  (readVariableAsInteger warn log: unify the displayed key format with the
  canonical `buildConfigKey(component, variable)` output instead of inline
  `${component}.${variable}` interpolation)

Imports updated: AbstractUIServer.ts extends to isNotEmptyArray;
UIServerAccessPolicy.ts extends to isNotEmptyArray.

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* fix(refactor): avoid TDZ cycles via utils barrel for new helper imports

The previous commits introduced fresh imports from `src/utils/index.js` in
three modules that participate in barrel-induced TDZ cycles via
`ConfigurationSchema.ts`:

- src/charging-station/ui-server/UIServerNet.ts is imported by
  ConfigurationSchema.ts, so importing back through utils/index.js produces
  `ReferenceError: Cannot access 'isHostLiteralWithoutPort' before
  initialization`.
- src/worker/WorkerAbstract.ts is exported via worker/index.js, which is
  consumed by ConfigurationSchema.ts (`WorkerProcessType`), so importing
  back through utils/index.js breaks Zod enum initialization with
  `TypeError: Cannot convert undefined or null to object` in
  `getEnumValues`.
- src/charging-station/TemplateSchema.ts uses the same cycle-prone barrel
  defensively, preempting future Zod-init breakage.

Resolution: import `convertToInt` and `isNotEmptyString` directly from
`./Utils.js` (the leaf module), mirroring the repo-established
cycle-avoidance pattern at `src/utils/ConfigurationMigrations.ts:3-4`
(`import { BaseError } from '../exception/BaseError.js'` with the same kind
of inline note).

Tests: previously failing
- tests/charging-station/ui-server/UIServerNet.test.ts
- tests/worker/WorkerUtils.test.ts
both pass; full suite 2857/2857.

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* fix(ocpp16): revert handleRequestChangeConfiguration integer-key check to Number()

Reverts the convertToInt swap at OCPP16IncomingRequestService.ts:785 (M4 in
the helpers audit). The downstream check
  `!Number.isInteger(numValue) || numValue < 0 → REJECTED`
intentionally relies on `Number(value)` returning `NaN` for invalid input
to fail `Number.isInteger`. `convertToInt` breaks this contract in four
distinct ways verified empirically:

  value         Number(value)  convertToInt(value)  effect on rejection check
  --------      -------------  -------------------  ----------------------
  undefined     NaN            0                    silently ACCEPTED
  ''            0              throws               uncaught exception
  '1.5'         1.5            1                    silently ACCEPTED
  'abc'         NaN            throws               uncaught exception

Two of these (`''` and `'abc'`) produce uncaught exceptions that propagate
out of the handler instead of returning Rejected. The other two silently
accept invalid values. The audit recommendation to adopt `convertToInt`
here was incorrect; the call site needs the NaN-on-invalid contract that
only `Number()` provides.

Issue flagged by hyperspace-insights review bot on PR #1931 (only the
`undefined → 0` case was highlighted; the full divergence is broader).

Inline note added to prevent re-introduction by future cleanups.

Tests: tests/charging-station/ocpp/1.6/OCPP16IncomingRequestService-Configuration.test.ts
and tests/charging-station/ocpp/1.6/OCPP16Integration-Configuration.test.ts —
14/14 pass.

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* refactor: dedup OCPP signing-flag checks and revert WorkerAbstract utils import

Applies four review nits from the post-audit self-review, plus reverts an
architectural misuse:

F1 — OCPPServiceUtils.ts (OCPP 2.0 signed-meter-value block, 3 sites):
extracts a private `isOCPP20FlagEnabled(chargingStation, component, variable)`
helper consuming the existing `convertToBoolean(getConfigurationKey(_,
buildConfigKey(_, _))?.value)` chain. Reduces 5-level indented blocks at
lines 938, 950, 963 to a single helper call, ~12 lines saved.

F2 — OCPP16ServiceUtils.ts (2 sites): adds `isSigningStartedReadingsEnabled`
and `isSigningUpdatedReadingsEnabled` static methods next to the existing
`isSigningEnabled`, all three following the same shape. Replaces the inline
`convertToBoolean(getConfigurationKey(_, OCPP16VendorParametersKey.X)?.value)`
chains at lines 173 and 826 with named-intent calls. Semantic naming
> generic helper since these are vendor-key-specific.

F3 — UIServerNet.ts, WorkerAbstract.ts, TemplateSchema.ts: compresses the
multi-line TDZ-cycle workaround comments to single-line format, matching
the established repo convention at `ConfigurationMigrations.ts:3`
(`// Direct path: the exception/index.js barrel re-exports OCPPError,
causing a TDZ cycle.`).

F4 — OCPP16IncomingRequestService.ts:786: shortens the 5-line `// NB:`
comment about `Number(value)` preservation to a 1-line concise note while
preserving the regression-prevention intent.

Architectural revert — WorkerAbstract.ts: removes the `isNotEmptyString`
import from `../utils/Utils.js` added in 7fe3e2a10 (M3). `src/worker/` is
a standalone sub-project and must not import from elsewhere in the source
tree. Reverted the call site to inline `workerScript.trim().length === 0`.
Same architectural rule that justifies deferring the worker-side
`mergeDeepRight`/`sleep`/`secureRandom` consolidations.

Tests: 37/37 pass on the affected suites (UIServerNet, WorkerUtils,
WorkerAbstract, OCPP16 Configuration, OCPP16 Configuration integration).

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* refactor(ocpp2): adopt handleIncomingRequestError across OCPP 2.0 handlers

Brings OCPP 2.0 incoming-request handlers to parity with the OCPP 1.6
equivalent (OCPP16IncomingRequestService.ts) by routing all catch-and-return
patterns through the shared `handleIncomingRequestError<T>` helper from
`src/utils/ErrorUtils.ts`.

Sites converted (14 total):

10 clean sites (A — single rejection-response return):
- handleRequestClearCache (line 815) — OCPP_RESPONSE_REJECTED
- handleRequestGetLocalListVersion (line 851) — { versionNumber: 0 }
- handleRequestSendLocalList (line 961) — OCPP_SEND_LOCAL_LIST_RESPONSE_FAILED
- handleRequestCertificateSigned (line 1583) — typed Rejected w/ statusInfo
- handleRequestDeleteCertificate (line 1849) — typed Failed w/ statusInfo
- handleRequestGetInstalledCertificateIds (line 1954) — typed NotFound w/ statusInfo
- handleRequestInstallCertificate (line 2109) — typed Failed w/ statusInfo
- handleRequestReset (line 2329) — typed Rejected w/ statusInfo
- handleRequestTriggerMessage (line 2864) — typed Rejected w/ statusInfo
- handleRequestUnlockConnector (line 2949) — typed UnlockFailed w/ statusInfo

4 conditional sites (G — nested + outer catches in handleRequestStartTransaction,
lines 2567/2608/2673/2737): each catch builds a typed
OCPP20RequestStartTransactionResponse local const (status: Rejected with the
appropriate additionalInfo, transactionId: generateUUID()) and passes it both
as errorResponse and as the `??` typing-narrowing fallback. The
`await this.resetConnectorOnStartTransactionError(...)` side-effect on the
outer catch is preserved before the helper call.

Imports added: ensureError, handleIncomingRequestError (alphabetical).

Behavioral note: the log message format changes from
`OCPP20IncomingRequestService.handleRequestX: ...` to
`ErrorUtils.handleIncomingRequestError: Incoming request command 'X' error: ...`
— matches the OCPP 1.6 service. The error itself is still logged (via the
helper's internal logger.error call); no error is swallowed.

Tests: 336/336 pass on `tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-*.test.ts`.

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* refactor(validation): adopt assertIsJsonObject for JSON-object type guards

Replaces the inline guard 'if (parsed == null || typeof parsed !== object ||
Array.isArray(parsed)) throw new BaseError(...)' with the canonical
'assertIsJsonObject(parsed, new BaseError(...))' helper from
'src/utils/Utils.ts'. The helper:

- Narrows the value type to JsonObject via 'asserts value is JsonObject'
  (type predicate gain).
- Accepts a custom Error second argument that is thrown verbatim, preserving
  the existing BaseError message and stack trace.

Sites converted (2):
- src/charging-station/TemplateValidation.ts:50 (validateTemplate)
- src/utils/ConfigurationValidation.ts:92 (validateConfiguration)

Imports updated: TemplateValidation.ts and ConfigurationValidation.ts both
extended to include assertIsJsonObject.

Tests: 74/74 pass on the validation suites.

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* refactor: adopt JSONStringify helper across src/

Replaces 14 bare 'JSON.stringify(_, undefined/null, 2)' call sites with the
shared 'JSONStringify' helper from 'src/utils/Utils.ts'. The helper:

- Serializes Maps and Sets safely. Bare 'JSON.stringify' silently drops Map
  entries (correctness fix for sites with potential Map values).
- Accepts an optional 'MapStringifyFormat' parameter to control how Maps are
  rendered (default: array of pairs; '.object': key-value object).

Sites with Map values (use 'MapStringifyFormat.object' to match the existing
JSON config-schema representation of station data):
- src/charging-station/ChargingStation.ts:2595 (config file write,
  ChargingStationConfiguration)
- src/charging-station/ui-server/mcp/MCPResources.ts:22 (listChargingStationData)
- src/charging-station/ui-server/mcp/MCPResources.ts:44 (station data by id)

Plain-object sites (default 'JSONStringify(_, 2)'):
- src/charging-station/BootstrapStateUtils.ts:150 (state file write)
- src/charging-station/ocpp/OCPPServiceUtils.ts:200 (validate.errors)
- src/charging-station/Bootstrap.ts:647 (worker message error)
- src/charging-station/ui-server/ui-services/AbstractUIService.ts:172
- src/charging-station/ui-server/mcp/MCPResources.ts:64 (templates list)
- src/charging-station/ui-server/mcp/MCPResources.ts:119 (commands list)
- src/charging-station/ocpp/OCPPResponseService.ts:100,112 (error PDU msgs)
- src/charging-station/ocpp/OCPPIncomingRequestService.ts:70,117,129 (error
  PDU msgs)

Also loosens the 'JSONStringify' generic constraint to 'object: unknown'
(matching 'JSON.stringify' standard signature). The previous restrictive
generic (already marked 'no-unnecessary-type-parameters') prevented passing
values like 'ChargingStationConfiguration' that lack TS index signatures
but are JSON-serializable at runtime. Removes unused 'JsonType' import.

Skipped (architectural):
- src/worker/WorkerSet.ts:191 — 'src/worker/' is a standalone sub-project
  and must not import from elsewhere (same rule as the WorkerAbstract revert
  in 2f190a4cf).

Tests: 512/512 pass on Utils + ChargingStation + Bootstrap + OCPP + UI server suites.

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* refactor(bootstrap): adopt promiseWithTimeout in waitChargingStationsStopped

Replaces the manual 'new Promise((resolve, reject) => { setTimeout(...);
waitChargingStationEvents(...).then(resolve).catch(reject) })' construct
with the shared 'promiseWithTimeout' helper from 'src/utils/Utils.ts'.

The refactor is behaviour-preserving:
- The timeout BaseError is now constructed once upfront and passed to
  'promiseWithTimeout' (matching the helper's pre-built error contract).
- The 'logger.warn' is emitted only when the caught error is referentially
  the timeoutError (i.e., only on actual timeout, not on errors propagated
  from 'waitChargingStationEvents'). Same semantic as the original which
  only logged inside the setTimeout callback.
- 'clearTimeout' on success path is handled internally by 'promiseWithTimeout'
  via 'promise.finally(() => clearTimeout(timer))'.

Linear try/await/catch flow replaces the nested Promise constructor +
.then/.catch/.finally chain (~5 LOC saved, less indirection).

Imports updated: Bootstrap.ts adds 'promiseWithTimeout' alphabetically to
the existing utils barrel import.

Tests: 11/11 pass on 'tests/charging-station/Bootstrap.test.ts'.

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* refactor: unify handleIncomingRequestError local-const pattern + doc identity check

F1 — OCPP20IncomingRequestService.ts (2 sites): converts the
constant-inlined-twice pattern at handleRequestClearCache:815 and
handleRequestSendLocalList:961 to the local-const pattern used by the other
11 sites in the same file. The error-response constant is now bound to a
typed local const and referenced once for both the helper's 'errorResponse'
parameter and the '?? errorResponse' TS-narrowing fallback, eliminating the
double reference to the same constant.

F3 — Bootstrap.ts waitChargingStationsStopped: adds a 3-line comment above
the 'if (error === timeoutError)' identity check documenting the contract
relied upon — 'promiseWithTimeout' must reject with the same Error instance
passed in. If a future maintainer wraps the error inside the helper, the
identity check would silently break and the timeout-specific 'logger.warn'
would no longer fire. The comment also clarifies that downstream errors
from 'waitChargingStationEvents' intentionally propagate unlogged
(preserves the original behaviour).

No behavioural change. Tests: 347/347 pass on OCPP20IncomingRequest +
Bootstrap suites.

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* refactor: apply v3 cross-validated review fixes

Applies 9 fixes from the 4-agent cross-validation review (2 oracles + 1
librarian + 1 explore agent on the same scope).

HIGH-CONFIDENCE fixes (>=2 agents converged):

CV1 — type-safety: 'isOCPP20FlagEnabled' parameter tightened from
'variable: string' to 'OCPP20OptionalVariableName | OCPP20RequiredVariableName
| OCPP20VendorVariableName'. The merged-runtime 'StandardParametersKey' /
'VendorParametersKey' objects only had OCPP 1.6 typedefs, masking the OCPP
2.0 call-site values; the explicit union closes the gap. (OCPPServiceUtils.ts)

CV2 — semantic preservation: 'convertToInt(evseKey)' in the Zod
'.superRefine' callback would throw on non-numeric keys, where the original
'Number(evseKey)' returned NaN and silently fell through. Reverted to
'convertToIntOrNaN(evseKey)' to preserve the NaN-on-invalid contract the
constraint check downstream relies on. Same class of bug the bot
identified at OCPP16IncomingRequestService:785. (TemplateSchema.ts)

CV3 — discriminator robustness: replaced 'error === timeoutError' identity
check with an 'instanceof TimeoutError' check against a 'BaseError'
subclass. The subclass discriminator survives any future wrapping of the
error inside 'promiseWithTimeout'; the identity check silently broke on
any error-cloning refactor of the helper. (Bootstrap.ts)

CV4 — cross-version parity: aligned the 4 remaining 'handleIncomingRequestError'
sites in OCPP 1.6 (cancelReservation, dataTransfer, getDiagnostics, reserveNow)
to the local-const pattern that OCPP 2.0 adopted in the audit, eliminating
the duplicated constant reference at each call site. (OCPP16IncomingRequestService.ts)

OBSERVABILITY fix:

S1 — reverted the 4 nested + outer catches in OCPP 2.0
'handleRequestStartTransaction' (Authorization / Group authorization /
Charging profile validation / outer 'Error starting transaction') from
'handleIncomingRequestError' adoption back to inline 'catch → logger.error →
return typed-rejection'. The helper's unified log message dropped the
phase-specific context (truncated idToken values, distinct phase names).
OCPP 1.6 'handleRequestRemoteStartTransaction' itself does NOT use
'handleIncomingRequestError', so this revert restores both observability
AND cross-version parity. The other 10 single-catch sites adopted by the
audit remain unchanged. (OCPP20IncomingRequestService.ts)

Doc/style nits:

S4 — README.md: 'SIMULATOR_COLD_START=true' documented as 'a truthy value
(true, True, TRUE, 1, optionally surrounded by whitespace)' to reflect
the actual 'convertToBoolean' semantics adopted in the audit.

S5 — Bootstrap.ts:649: added 'MapStringifyFormat.object' to the
'JSONStringify(data, 2)' call on worker-message error data. Consistent
with the format choice at 'ChargingStation.ts:2597' and 'MCPResources.ts'
since worker message data can carry Maps.

S6 — TemplateSchema.ts:3: TDZ-cycle direct-path comment realigned to the
declarative form used by 'ConfigurationMigrations.ts:3' ('the X barrel
re-exports Y, causing a TDZ cycle.').

S7 — OCPP16ServiceUtils.ts:683,694: JSDoc terminology aligned with the
spec ('signing of meter values at transaction start' / 'during transaction
updates' instead of 'transaction-started readings' / 'transaction-updated
readings').

Tests: 520/520 pass on OCPP 2.0 IncomingRequest, OCPP 1.6 IncomingRequest,
Bootstrap, TemplateSchema, TemplateValidation suites.

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* refactor: apply v4 cross-validation review fixes

Applies 10 fixes from the v4 4-agent cross-validation review (2 oracles + 1
librarian + 1 explore) on the post-v3 state of PR #1931.

CORRECTNESS:

BUG — Configuration.ts:293: 'convertToInt(env.PORT ?? "")' would throw an
uncaught exception when PORT is unset (?? '' returns empty string, on which
convertToInt throws). Same class of bug as the CV2 fix in TemplateSchema.
Guards with isNotEmptyString(env.PORT) and only converts when set.

DESIGN:

HC1 + Q2(d) — TimeoutError relocated from Bootstrap.ts to a dedicated
src/exception/TimeoutError.ts file, exported from src/exception/index.js.
Bootstrap.ts now imports TimeoutError alongside BaseError. Matches the
established repo pattern (BaseError, OCPPError each in their own file
under src/exception/). The class declaration is no longer interleaved
with imports in Bootstrap.ts.

S1 — extract rejectedInternalError(additionalInfo) helper in
OCPP20IncomingRequestService.ts. Collapses 4 near-identical 8-line
'{ status: Rejected, statusInfo: { additionalInfo, reasonCode:
InternalError }, transactionId: generateUUID() }' literals at the 4
nested + outer catches of handleRequestStartTransaction. Each catch
becomes a 1-line 'return rejectedInternalError("...")'.

S2 — wrap 'logger.error(..., error)' with 'ensureError(error)' at the
4 reverted G-sites. Aligns with the 10 sibling sites in the same file
that already route through ensureError. Non-Error throwables are
normalized before reaching the logger.

Q3(a) — TemplateSchema.ts: add 'Number.isNaN(evseId)' guard in the
.superRefine loop over Object.entries(template.Evses). Closes the
validation gap where 'Evses: { "abc": {...} }' silently passed
because NaN === 0 / NaN > 0 are both false. The guard pushes a
'ctx.addIssue' for non-numeric keys with a clear message citing
OCPP 2.0.1 §7.2.

POLISH:

S3 — Bootstrap.ts: 'logger.warn(..., error.message)' instead of the
closed-over 'timeoutError.message'. After the instanceof TimeoutError
narrowing, 'error' is correctly typed as TimeoutError. If a future
wrap of promiseWithTimeout passes through a different instance,
error.message still reflects what was actually caught.

S4 — Bootstrap.ts: 'behaviour' (British) → 'behavior' (American)
to match the 30+ occurrences elsewhere in src/. Also resolves the
cspell flag.

S5 — Bootstrap.ts: catch comment compressed from 3 lines to 1 line.
Drops the partial redundancy with the TimeoutError JSDoc and removes
the 'unlogged' cspell flag.

HC2 — README.md: SIMULATOR_COLD_START wording tightened from a
non-exhaustive list ('true, True, TRUE, 1') to the exhaustive rule
'case-insensitive true or 1, optionally surrounded by whitespace'.
Matches the actual convertToBoolean semantics (trim + toLowerCase).

Tests: 2832/2832 pass, 0 fail.

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* refactor: apply v5 cross-validation review fixes

HC-A TemplateSchema.ts: replace NaN-via-arithmetic guard with explicit
Number.isInteger check for evseId validation (clearer intent, same semantics).

HC-B + HC-C TimeoutError.ts: compress JSDoc to single line; add SPDX
copyright header to match peer OCPPError.ts.

L-A + HC-D OCPP20IncomingRequestService.ts: rename rejectedInternalError
helper to buildRejectedResponse(additionalInfo, reasonCode) and migrate
all 11 RequestStartTransaction rejection sites (4 helper calls + 7 inline
literals) through it. Drop fabricated transactionId: generateUUID() from
rejection responses per OCPP 2.0.1 §E07: that field signals a transaction
already started by the Charging Station before the request arrived
(cable-first scenarios), so fabricating a UUID on a pure rejection
misleads CSMS implementations that map remoteStartId -> transactionId.

V5-6 Bootstrap.ts: relocate catch-block ordering comment below instanceof
check and reword for clarity.

Test: update §E07 regression assertion in
OCPP20IncomingRequestService-RequestStartTransaction.test.ts to expect
response.transactionId === undefined on TxInProgress rejection (was
incorrectly asserting notStrictEqual against the now-fixed bug).

All quality gates pass: 2857/2857 tests, typecheck, lint, format.

* docs(ocpp20): correct spec citation §E07 → §F01.FR.13

The 'transactionId-on-rejection' fix in commit 23beb7ea1 cited OCPP 2.0.1
§E07, which is wrong: §E07 is 'Transaction locally stopped by IdToken'.

The actual clause governing transactionId echo on RequestStartTransaction-
Response is §F01.FR.13: 'When a transaction is created on the Charging
Station, but has not been authorized. AND RequestStartTransactionRequest
is received → The Charging Station SHALL return the transactionId in the
RequestStartTransactionResponse.'

This is the cable-plugin-first scenario. On pure rejections F01.FR.13 does
not apply, so transactionId is correctly omitted. The semantics of the fix
are unchanged; only the spec citation in the comments is corrected.

Verified against OCPP-2-0-1-edition3-part2-specification.md via local
spec collection.

* refactor: apply v6 cross-validation review fixes

V6-S2 + V6-Q1 (HIGH, both Oracle agents): RequestStartTransaction
TxInProgress rejection regressed OCPP 2.0.1 part 2 §F01.FR.13 when the
connector held a transactionPending state. The v5 fix dropped the
fabricated UUID (correct) but also dropped the real connectorStatus.
transactionId (incorrect). Per §F01.FR.13: 'When a transaction is created
on the Charging Station, but has not been authorized AND
RequestStartTransactionRequest is received, the Charging Station SHALL
return the transactionId in the RequestStartTransactionResponse.'

Split the TxInProgress check into two branches:
- transactionPending === true AND typeof connectorStatus.transactionId
  === 'string' → buildRejectedResponse(TxStarted, ..., transactionId).
  The appendix distinguishes TxStarted (cable-plugin-first, not yet
  authorized) from TxInProgress (authorized and running) — TxStarted
  matches the §F01.FR.13 precondition.
- Fallthrough (transactionStarted || transactionPending-without-tx-id ||
  locked) → buildRejectedResponse(TxInProgress, ...) without echo.

Extended buildRejectedResponse signature: added optional transactionId?:
UUIDv4 parameter and swapped to (reasonCode, additionalInfo,
transactionId?) — required-first/optional-last per V6-X3+Q11. All 11
existing callsites updated; the new branch is the 12th call.

V6-S1 (M, Oracle1, qmd-verified): TemplateSchema.ts cited OCPP 2.0.1
§7.2 (Connector numbering) for EVSE-key validation. The correct clause
is §7.1 (EVSE numbering) — qmd against
OCPP-2.0.1_edition3_part1_architecture_topology.md:269 confirms §7.1
covers 'EVSEs MUST be sequentially numbered starting from 1' and 'evseId
0 is reserved'. Two citation occurrences corrected.

V6-S4 (L, Oracle1): F01.FR.11 violation (ChargingProfile.transactionId
set in RequestStartTransaction) used reasonCode InvalidValue. F01.FR.26
hints InvalidProfile/InvalidSchedule for invalid ChargingProfile.
Changed to InvalidProfile for family coherence with the other two
ChargingProfile rejection sites.

V6-X1 (L, Librarian): Helper comment overstated the spec — said
'fabricating a UUID misleads CSMS' as if §F01.FR.13 mandated MUST NOT.
Reworded to distinguish the positive obligation (SHALL echo when
precondition holds) from the practical argument (fabrication misleads).
Documents the new optional transactionId? parameter semantics.

V6-X14 (M, Librarian): TimeoutError extends BaseError {} was an empty
subclass — TypeScript structural typing made BaseError and TimeoutError
indistinguishable in the type system, so the single instanceof check in
Bootstrap.ts relied solely on the runtime prototype chain. Added
'public override readonly name = TimeoutError as const' for nominal
distinction (matches the OCPPError pattern in the same codebase).

V6-Q2 (M, Oracle2): Test coverage gap — only 1 of the 11 v5 rejection
paths asserted transactionId === undefined. Added the assertion to the
6 remaining unprotected Rejected tests (InvalidIdToken, MasterPassGroup,
invalid ChargingProfile purpose, ChargingProfile-with-transactionId,
authorization throw, all-EVSEs-inoperative). The V6-S2 fix test now
asserts transactionId === pendingTransactionId AND reasonCode ===
TxStarted (regression-locks the §F01.FR.13 echo path).

Deferred to follow-up: V6-Q3 (mixed catch pattern — intentional per
v3 S1), V6-E11/E12/E15 (ensureError missing at 18 logger.error sites
in OCPP 2.0 .catch callbacks + 2 OCPP 1.6 catches), V6-E19 (extract
helper for RequestStopTransaction rejection literals — needs different
return type), V6-X3 cosmetic polish items.

Verified via qmd ocpp-specs collection: §F01.FR.13, §7.1 vs §7.2,
§2.10, B09.FR.31 (errata 2025-09 §2.12 — exists as cited), TxStarted
vs TxInProgress appendix distinction. V6-E3 (Explorer's HIGH flag on
B09.FR.31) was a false positive — citation is valid.

All quality gates pass: typecheck, lint, format, 2857/2857 tests.

* refactor: apply v7 cross-validation review fixes

V7 review pass — 4 agents (oracle ×2, librarian, explore) with v6 lessons
integrated as new criteria: regression-chain audit, type-cast scrutiny,
test-gap blast-radius, helper-signature swap audit, false-positive
detection via qmd. Convergent findings reconciled into 11 in-scope fixes.

False positives rejected (lesson from V6-E3/E4):
- V7-E2/E17 (Explore HIGH unique): claimed startTransactionOnConnector
  misses transactionPending=true. REJECTED — ATG path sends
  TransactionEvent(Started) with triggerReason=Authorized, not cable-
  plugin-first. Setting transactionPending=true there would create
  spurious echoes on already-authorized connectors.

Fixes applied:

V7-Q1+X5 (idiom): swap ternary spread '?: {}' to '&& { x }' to match
the dominant codebase pattern (ChargingStation.ts, OCPP16TestUtils.ts,
AbstractUIService.ts).

V7-Q4+S1 (helper docstring): trim from 6 lines to 3 + reword to avoid
implying §F01.FR.13 mandates status=Rejected (spec is silent on status).

V7-Q5 (test comment): trim P4 duplicate of helper docstring to 1-line
anchor — assertions already self-document.

V7-Q7+S2+X6 (UUIDv4 cast safety): add inline justification comment at
the 'as UUIDv4' cast site pointing to the source-of-truth write site
(line ~2740 generateUUID() assignment).

V7-Q2 (reasonCode lock): add statusInfo.reasonCode assertions to 6 v6-Q2
tests so a future bug swapping codes (e.g., InvalidIdToken vs
InvalidValue) cannot pass: InvalidIdToken×2, InvalidProfile×2,
InternalError×1, NotFound×1.

V7-Q3 (residual TxInProgress test): add test exercising the fallthrough
branch via connectorStatus.locked = true — verifies reasonCode=TxInProgress
and transactionId=undefined (the V6-S2 split's other branch).

V7-S6+E4 (MasterPass coverage): add transactionId=undefined +
reasonCode=InvalidIdToken assertions at MasterPass.test.ts:112 (V6-Q2
pattern spillover).

V7-E5 (RequestStopTransaction coverage): add reasonCode assertions
(InvalidValue×3) to the 3 invalid-UUID-format rejection tests.

V7-E7 (test describe label): TemplateSchema.test.ts:235 describe block
updated to reflect §7.1 EVSE numbering + §7.2 connector numbering split
applied at v6-S1.

V7-E8 (citation qualifier): TemplateSchema.ts:285,295 inline error
messages use 'part 1 §7.2' for consistency with v6-S1 correction style.

V7-E9 (empty subclass discriminants): add 'public override readonly
name = ... as const' to 5 error subclasses (OCPPError,
TemplateValidationError, PayloadTooLargeError,
ConfigurationValidationError, AuthenticationError — last had non-readonly
non-as-const override). Extends V6-X14 TimeoutError pattern uniformly.

Deferred (rationale documented):
- V7-Q6+X4 — TimeoutError commit-body typo and 'matches OCPPError'
  inaccuracy — git history immutable, factual note belongs in PR desc.
- V7-E1 — ConnectorStatus.transactionId: number|string union (OCPP 1.6
  numeric IDs share the field). Narrowing requires OCPP 1.6 refactor.
- V7-E3+E6+E10+E12+E13+E14+E15+E16+E18 — verified clean / not in scope
  (defensive code correct, no echo fields, plausible 1.6 citations).

Verified via qmd ocpp-specs (OCA FINAL schema, lorenzodonini/ocpp-go,
mobilityhouse/ocpp, citrineos-core, EVerest/libocpp): TxStarted vs
TxInProgress appendix distinction matches v6 split; transactionId on
status=Rejected is schema-valid; no reference impl contradicts v6;
typeof===string + as UUIDv4 cast is safe given OCPP 2.0 invariant.

All quality gates pass: typecheck, lint, format, 2852/2852 tests.

* refactor: apply v8 cross-validation review fixes

V8 review pass — 4 agents (oracle ×2, librarian, explore) with v7 lessons
elevated to criteria: recursive false-positive detection, V7-E9 blast
radius audit, V7-Q1 idiom equivalence under JSON.stringify, V7-Q3 test
isolation, convergence saturation indicator (<3 substantive = bottom).

3 agents declared SATURATION REACHED (Oracle1, Oracle2, Librarian). 1
agent (Explore) found 3 NEW substantive findings + 1 echo, breaking
saturation.

False positive rejected (lesson from V6-E3, V7-E2):
- V8-E2 (Explore L) — claimed BaseError has no name override and would
  emit name='Error' on direct instantiation. REJECTED: BaseError's
  constructor already does this.name = new.target.name, so direct
  instantiation yields name='BaseError' correctly. Caller-trace
  verification (same v7 criterion) eliminated this.

Fixes applied:

V8-E1 (M) — Extend V7-E9 pattern to ui/common package missed entirely
by v7. ConnectionError and ServerFailureError in ui/common/src/errors.ts
used mutable constructor-assignment 'this.name = ...' instead of the
class-field pattern. Replaced with 'public override readonly name =
"<ClassName>" as const' class fields, removed the constructor
assignments. Now all 8 error subclasses across src/ and ui/common share
the same nominal-discriminant pattern.

V8-E3 (L) — handleRequestStartTransaction had 1 remaining inline
rejection literal at line 2516 (the resolvedEvseId == null branch with
reasonCode=NotFound) bypassing the buildRejectedResponse helper used by
all 11 other rejection returns in the same function. Replaced with
buildRejectedResponse(ReasonCodeEnumType.NotFound, 'No available EVSE
found for remote start') for invariant coherence.

V8-S1 (L) — RequestStopTransaction test 'should reject stop transaction
for non-existent transaction ID' (line 133) used 'non-existent-
transaction-id' as input, which fails UUID format validation at line
2783 and never reaches the TxNotFound branch (line 2796). The test
covered InvalidValue (format-rejection), not TxNotFound. Renamed test
to '... for invalid transaction ID format - non-UUID string' to match
actual coverage + added new test 'should reject stop transaction for
valid UUID with no matching transaction' that uses
'00000000-0000-4000-8000-000000000000' (valid UUIDv4 format, no
matching transaction) and asserts reasonCode=TxNotFound. The real
TxNotFound branch is now covered.

Deferred (rationale documented):
- V8-S2/Q1 — line-number pointer '~2740' in the as UUIDv4 cast comment
  is fragile but acceptable; tilde already signals approximation.
- V8-S3/Q2 — §F01.FR.13 anchor appears at 4 sites (helper docstring,
  branch comment, V6-S2 test comment, V7-Q3 test name). Each lives at a
  distinct cognitive level (helper API vs handler decision vs test
  intent); not collapsible.
- V8-E4 (echo of V7-S7) — handleRequestStopTransaction inline literals
  return a different response type (OCPP20RequestStopTransactionResponse);
  buildRejectedResponse cannot be reused. Helper-extraction follow-up
  out of scope.
- V8-E5 (echo) — 3 ?: {} sites in test-helper files. Already noted.

Saturation declaration:
- v8 found 3 substantive findings (V8-E1, V8-E3, V8-S1). v9 sweep would
  target: (a) ui/common error subclasses (now all readonly as const),
  (b) buildRejectedResponse call sites (now all 13 use the helper), (c)
  RequestStopTransaction test coverage (now exercises both InvalidValue
  and TxNotFound branches). Expected v9 finding count: 0.

Verified via qmd ocpp-specs (5 audits cumulative), empirical Node.js
v24 testing (V7-E9 stack trace + JSON.stringify + util.inspect +
winston format paths), and external references (5 OSS OCPP impls + 5
major TS error-class repos for readonly-name canonical confirmation).

All quality gates pass: typecheck (src + ui/common), lint (src +
ui/common), format, tests (src 2859/2865, ui/common 120/120, 0 fail).

---------

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
4 weeks agotest: use shared response test helpers (#1930)
Jérôme Benoit [Mon, 29 Jun 2026 18:24:09 +0000 (20:24 +0200)] 
test: use shared response test helpers (#1930)

* test(ui-server): use response drain helpers

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* test(ocpp): share response service test helpers

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* test(ocpp): use auth factory test instances

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* refactor(ocpp): move ResponseService testable wrapper to __testable__

Aligns OCPP 2.0 ResponseService test wiring with the established pattern used
by RequestService and VariableManager:

- New src/charging-station/ocpp/2.0/__testable__/OCPP20ResponseServiceTestable.ts
  exporting createTestableResponseService + TestableOCPP20ResponseService;
  mirrors OCPP20RequestServiceTestable.ts structure (function declarations,
  file-level JSDoc with @example, double-cast through unknown).
- __testable__/index.ts re-exports the new module.
- OCPP20ResponseService-TransactionEvent.test.ts and -ForceTxOnInvalid.test.ts
  now import createTestableResponseService from __testable__/index.js;
  call sites renamed (drops redundant OCPP20 prefix, consistent with
  createTestableRequestService).
- OCPP20ResponseServiceTestUtils.ts now contains only the test-only
  buildTransactionEventRequest payload builder; adopts @file header and
  arrow-style export to harmonize with MockFactories.ts neighbour.

Test-only refactor; no production behavior change.

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* test(ocpp): factor auth service injection helpers

Centralizes the two patterns used by OCPP 2.0 IncomingRequest tests to
inject mock OCPPAuthService instances into the factory cache:

- injectMockAuthService(station, service) — wraps the station-id lookup +
  OCPPAuthServiceFactory.setInstanceForTesting plumbing; replaces the
  duplicated boilerplate in ClearCache.test.ts and LocalAuthList.test.ts.
- withThrowingAuthServiceFactory(errorMessage, fn) — scoped monkey-patch +
  try/finally restore for the 3 sites that must exercise a failing
  getInstance (factory unavailable, no Authorization Cache support).
  Required because setInstanceForTesting only injects a returned instance;
  it cannot make getInstance itself throw.

Side effect of the helper adoption:

- LocalAuthList.test.ts drops the suite-scoped originalGetInstance save
  and afterEach restore — no longer needed since every monkey-patch is
  now scoped to its own test via withThrowingAuthServiceFactory.
- ClearCache.test.ts drops the local try/finally on the no-cache-support
  test for the same reason.

Test-only refactor; no production behavior change.

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* test(ocpp): align auth mock helper verbs and drop dead return type

V2-1: harmonize JSDoc verbs across the auth injection helpers — `inject*`
docstrings now start with 'Inject' to match the function name, and the local
`setupMockAuthService` wrappers (which build the mock + inject it) now start
with 'Configure and inject' to reflect their broader role. Avoids the verb
collision between the shared `injectMockAuthService` and the local
`setupMockAuthService` helpers.

V2-2: drop the dead `OCPPAuthService` return type from LocalAuthList's local
`setupMockAuthService` — no call site captured the return value. Also drops
the now-unused `OCPPAuthService` type import.

Test-only refactor; no production behavior change.

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* test(ocpp): align ClearCache test hook ordering with siblings

Moves the `afterEach` block below `beforeEach` in ClearCache.test.ts to match
the before-then-after ordering used by LocalAuthList, TransactionEvent and
ForceTxOnInvalid test files. Cosmetic; no behavior change.

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
---------

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
4 weeks agofix(deps): update all non-major dependencies (#1926)
renovate[bot] [Mon, 29 Jun 2026 13:25:43 +0000 (15:25 +0200)] 
fix(deps): update all non-major dependencies (#1926)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
4 weeks agochore: release main (#1911) cli@v4.10.0 ocpp-server@v4.10.0 simulator@v4.10.0 ui-common@v4.10.0 web@v4.10.0
Jérôme Benoit [Sun, 28 Jun 2026 22:29:24 +0000 (00:29 +0200)] 
chore: release main (#1911)

* chore: release main

* [autofix.ci] apply automated fixes

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
5 weeks agofix(deps): update all non-major dependencies (#1925)
renovate[bot] [Fri, 26 Jun 2026 18:38:57 +0000 (20:38 +0200)] 
fix(deps): update all non-major dependencies (#1925)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
5 weeks agofix(ui-server): classify broadcast responses without handlers (#1924)
Jérôme Benoit [Wed, 24 Jun 2026 20:11:56 +0000 (22:11 +0200)] 
fix(ui-server): classify broadcast responses without handlers (#1924)

* fix(ui-server): classify broadcast responses without handlers

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* fix(ui-server): preserve internal broadcast origin

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* fix(ui-server): cleanup broadcast aggregation reliably

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* test(ui-server): ensure late response cleanup

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* refactor(types): introduce UIRequestOrigin enum and widen ProtocolRequestHandler

- Promote 'internal' | 'transport' string-literal union to enum
  UIRequestOrigin { INTERNAL, TRANSPORT } hosted in src/types/UIProtocol.ts.
  Matches SCREAMING_SNAKE_CASE convention used across the file
  (ProcedureName, ResponseStatus, BroadcastChannelProcedureName).
- Promote UIRequestContext interface to src/types/UIProtocol.ts.
- Widen canonical ProtocolRequestHandler with optional context? parameter
  (backwards-compatible).
- Re-export UIRequestOrigin and UIRequestContext from src/types/index.ts.
- Drop the local UIServiceProtocolRequestHandler shadow in AbstractUIService.ts;
  consume the canonical ProtocolRequestHandler.
- Replace inline 'internal' / 'transport' literals at the two AbstractUIService
  defaults and the AbstractUIServer.sendInternalRequest call site.

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* refactor(ui-server): tighten handleProtocolRequest, rename log context, align terminology

- Tighten the canonical ProtocolRequestHandler in src/types/UIProtocol.ts:
  required params (uuid, procedureName, payload) match the runtime invariant
  guaranteed by the ProtocolRequest tuple destructure in requestHandler();
  return union collapses to Promise<ResponsePayload | undefined> | ... | undefined.
- Tighten handleProtocolRequest signature accordingly and drop the dead
  'Invalid protocol request' runtime throw (unreachable: requestHandler only
  calls it after the tuple has been destructured into non-null values).
- Rename interface BroadcastResponseLogContext to
  BroadcastChannelResponseLogContext for symmetry with the
  BroadcastChannel{ProcedureName, RequestContext, ...} family, and export
  it so test helpers can type-check the structured log payload.
- Align log strings in logBroadcastResponseWithoutHandler from
  'transport handler' to 'response handler' to match the public API
  (hasResponseHandler / responseHandlers). Test names and regex matchers
  updated atomically.

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* style(ui-server): rename active to stopped and document rollback invariants

- Rename AbstractUIService.active boolean to stopped (default false, flipped
  by stop()). The branch label 'late broadcast response after UI service
  stop' now matches the field name; the read site at
  logBroadcastResponseWithoutHandler reads as 'if (this.stopped) debug,
  else warn' without needing a comment.
- Invert the two log branches under requestContext == null accordingly:
  the stopped branch leads with debug, the active branch with warn.
- Add a 1-line rationale comment on the try/catch in
  sendBroadcastChannelRequest documenting the bookkeeping/dispatch
  atomicity invariant (rollback the just-recorded context if the worker
  dispatch throws).
- Add a 1-line rationale comment on the try/finally in
  UIServiceWorkerBroadcastChannel.responseHandler documenting the
  aggregation-state release invariant under a sendResponse throw.

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* test(ui-server): extract expectSingleLog, assert log context, regress dispatch rollback

- Add expectSingleLog(mocks, level, pattern, contextShape?) to
  UIServerTestUtils.ts. Collapses the repeated
  'warnMock.length === 0 / debugMock.length === 1 + typeof guard +
  assert.match' block previously duplicated across six broadcast-response
  tests. Accepts an optional structured contextShape that, when provided,
  deep-equals the second logger argument against the
  BroadcastChannelResponseLogContext payload.
- Rewrite the six broadcast-response classification tests to invoke the
  helper. Each test now asserts the full structured log context (origin,
  procedureName, status, uuid, hashIds{Failed,Succeeded}), not just the
  message string — catching regressions that swap origin, drop
  procedureName, or omit hashIds propagation into the log payload.
- Add a new regression test 'should rollback expected responses when
  broadcast dispatch throws' that stubs uiServiceWorkerBroadcastChannel
  .sendRequest to throw, calls service.requestHandler with a broadcast
  procedure, and asserts service.getBroadcastChannelExpectedResponses
  rolled back to 0 — locking the try/catch invariant introduced in this
  PR.

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* refactor(ui-server): address post-design-pass nits

- tests: replace Reflect.get + double 'as never' chain in the broadcast
  dispatch rollback regression test with a typed cast to
  UIServiceWorkerBroadcastChannel. Kills both 'as never' casts and the
  '(): never' return annotation. Field rename now fails the test loudly
  via TS instead of silently mis-testing.
- tests: rename 'should warn on untracked broadcast responses while
  service is active' to '... before service stop' so the test name
  matches the post-rename 'stopped' field vocabulary.
- types: add JSDoc on the new public surface introduced by this PR -
  UIRequestOrigin, UIRequestContext, ProtocolRequestHandler widening
  (UIProtocol.ts), and BroadcastChannelResponseLogContext
  (AbstractUIService.ts). Docs describe contract, not mechanism.

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
---------

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
5 weeks agochore(deps): update all non-major dependencies (#1923)
renovate[bot] [Wed, 24 Jun 2026 15:06:27 +0000 (17:06 +0200)] 
chore(deps): update all non-major dependencies (#1923)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
5 weeks agofix(build): externalize prom-client from bundle
Jérôme Benoit [Wed, 24 Jun 2026 00:00:01 +0000 (02:00 +0200)] 
fix(build): externalize prom-client from bundle

5 weeks agodocs(readme): remove uiServer.metrics migration notes
Jérôme Benoit [Tue, 23 Jun 2026 23:06:59 +0000 (01:06 +0200)] 
docs(readme): remove uiServer.metrics migration notes

Drop the `#### Migration notes` block under the worker process model
section that documented the `uiServer.metrics.enabled=true` listener-
inheritance change. Operational guidance lives with the feature
documentation; this stand-alone block was redundant.

5 weeks agofeat(ui-server): expose Prometheus /metrics on ws and mcp transports (#1921)
Jérôme Benoit [Tue, 23 Jun 2026 22:35:14 +0000 (00:35 +0200)] 
feat(ui-server): expose Prometheus /metrics on ws and mcp transports (#1921)

The opt-in Prometheus `/metrics` endpoint introduced by #1912 was wired
only into `UIHttpServer`. With `uiServer.type` set to `ws` or `mcp`, the
endpoint was silently disabled with a startup warning.

Move the metrics registry, the request handler, and the soft-cap
accounting from `UIHttpServer` up to `AbstractUIServer`, then expose
`/metrics` on the shared `httpServer` from `UIWebSocketServer` and
`UIMCPServer` via a single `tryServeMetrics` template method that
encapsulates the `metrics.enabled` gate, the path predicate, the
authentication step, and the scrape handler. The
`uiServer.metrics.{enabled,softSampleCap}` configuration block is
preserved; the gauge registry is re-used unchanged; and `accessPolicy`,
rate-limiting, and `authentication` apply identically on all three
transports.

The lifecycle is bracketed by a public `start()` template-method on
`AbstractUIServer`; subclasses implement the `attachTransport()`
protected hook and may override the symmetric `detachTransport()` hook.
A one-shot `transportAttached` guard is set BEFORE `attachTransport()`
runs so a throwing hook cannot leave a half-attached server admitting a
silent retry; on failure any partially-registered `'request'` /
`'upgrade'` listeners on `httpServer` are stripped and the guard is
rolled back. `metricsScrapeChain` is reseated to `Promise.resolve()`
at every `start()` so cycle N+1 never awaits cycle N's scheduled
registry clear; in `runMetricsScrape`, the per-scrape
`metricsSampleCount` is captured into a local snapshot BEFORE
`await registry.metrics()` yields, so a peer scrape dispatched after
a sync `stop()`/`start()` cannot overwrite the count the soft-cap
branch reads. The corresponding `stop()` is symmetrically defensive:
both `detachTransport()` and each `uiService.stop()` are wrapped in
per-iteration `try/catch` so a throwing override does not abort
downstream teardown (`stopHttpServer`, remaining services, handlers,
caches), and the `transportAttached = false` reset runs in a `finally`
block so a subsequent `start()` is never permanently locked out — any
other unexpected throw still propagates. A shared
`renderNotFoundAndDestroy(req, res)` helper consolidates the 404
fallback used by the WebSocket and MCP request listeners; the helper
delegates socket teardown to `destroyHttp1SocketIfPending(req)` which
skips `req.destroy()` on HTTP/2 streams (lifecycle owned by the
`Http2Stream`) and on already-complete HTTP/1.1 requests (keep-alive
pool).

No new configuration surface, no new listener: `/metrics` is served on
the same `Http2Server | Server` instance that already backs the
WebSocket protocol upgrade in `UIWebSocketServer` and the `/mcp` route
in `UIMCPServer`. HEAD responses emit an explicit `Content-Length`
equal to the GET body byte length (`Buffer.byteLength(body, 'utf8')`)
and an empty body, per RFC 9110 §9.3.2. A frozen
`METRICS_ALLOWED_LABEL_NAMES` tuple pins the canonical PII surface; the
`isMetricsAllowedLabelName` type-guard predicate is the O(1) lookup,
and a guardian spec asserts every gauge label name in the rendered
registry is admitted.

Operator-visible change: `uiServer.metrics.enabled=true` with
`type='ws'` or `'mcp'` now serves `/metrics` whereas the previous
release silently disabled it. Operators relying on the silent disable
must set `metrics.enabled=false` (or omit the block) and audit firewall
posture on the UI server port. `HEAD /metrics` (e.g. `curl -I`) returns
identical headers to `GET` (including `Content-Length`) with an empty
body. README updated.

Closes #1917
Refs #851, #1912

5 weeks agochore(deps): lock file maintenance (#1920)
renovate[bot] [Tue, 23 Jun 2026 19:04:56 +0000 (21:04 +0200)] 
chore(deps): lock file maintenance (#1920)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
5 weeks agochore(deps): update all non-major dependencies (#1922)
renovate[bot] [Tue, 23 Jun 2026 16:42:40 +0000 (18:42 +0200)] 
chore(deps): update all non-major dependencies (#1922)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
5 weeks agochore(deps): update all non-major dependencies to v11.8.0 (#1919)
renovate[bot] [Mon, 22 Jun 2026 14:27:08 +0000 (16:27 +0200)] 
chore(deps): update all non-major dependencies to v11.8.0 (#1919)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
5 weeks agochore(deps): update all non-major dependencies (#1918)
renovate[bot] [Sun, 21 Jun 2026 13:13:05 +0000 (15:13 +0200)] 
chore(deps): update all non-major dependencies (#1918)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
6 weeks agofeat(ui-server): add opt-in Prometheus /metrics endpoint (closes #851) (#1912)
Jérôme Benoit [Sat, 20 Jun 2026 18:47:27 +0000 (20:47 +0200)] 
feat(ui-server): add opt-in Prometheus /metrics endpoint (closes #851) (#1912)

* feat(ui-server): add opt-in Prometheus /metrics endpoint (closes #851)

Expose simulator state on GET /metrics when uiServer.metrics.enabled=true.
The endpoint is grafted into UIHttpServer.requestListener after the
existing runRequestPrologue (access policy + rate limit) and authenticate
calls, so it inherits per-client rate limiting, host/origin/proxy
validation and basic-auth credentials with no additional surface.
Prometheus is HTTP-only by spec; the flag is honoured only on
uiServer.type=http and AbstractUIServer.warnIfMisconfigured logs a
warning otherwise.

Metric families (exhaustive within the data already exposed via
ChargingStationData and Bootstrap.getState()):

* Global: simulator_info{version}, simulator_started,
  simulator_charging_station_templates_total,
  simulator_charging_stations_{configured,provisioned,added,started}_total,
  simulator_ui_server_known_stations_total,
  simulator_template_{added,configured,provisioned,started}{template}.
* Per charge station (label hash_id): simulator_station_info{vendor,
  model, firmware_version, ocpp_version, current_out_type},
  simulator_station_started, simulator_station_ws_state (numeric 0-3),
  simulator_station_connectors_total, simulator_station_evses_total,
  simulator_station_max_power_watts, simulator_station_max_amperage_amperes,
  simulator_station_voltage_out_volts,
  simulator_station_data_timestamp_seconds,
  simulator_station_boot_status_info{status},
  simulator_station_boot_heartbeat_interval_seconds,
  simulator_station_atg_enabled,
  simulator_station_diagnostics_status_info{status},
  simulator_station_firmware_status_info{status},
  simulator_station_ocpp_config_keys_total.
* Per connector (labels hash_id, connector_id):
  simulator_connector_status_info{status} (one-hot over
  ConnectorStatusEnum), simulator_connector_boot_status_info{status},
  simulator_connector_availability_info{availability},
  simulator_connector_error_code_info{error_code},
  simulator_connector_type_info{connector_type},
  simulator_connector_locked,
  simulator_connector_transaction_{started,pending,remote_started},
  simulator_connector_transaction_seq_no,
  simulator_connector_transaction_event_queue_size,
  simulator_connector_transaction_id (numeric value only, never label),
  simulator_connector_transaction_start_seconds,
  simulator_connector_transaction_energy_active_import_register_wh,
  simulator_connector_energy_active_import_register_wh,
  simulator_connector_max_power_watts,
  simulator_connector_charging_profiles_total,
  simulator_connector_evse_id, simulator_connector_reservation_active.

PII reject-list (HARD): supervisionUrl, chargeBoxSerialNumber,
chargePointSerialNumber, meterSerialNumber, iccid, imsi,
authorizeIdTag/localAuthorizeIdTag/transactionIdTag/transactionGroupIdToken
and OCPP customData. transactionId / remoteStartId never used as labels
(unbounded cardinality). Adversarial label values are escaped per
prom-client's exposition-format rules so synthesized # HELP / # TYPE
lines cannot be injected.

Cardinality soft cap: a single warning log per scrape when total
samples exceed METRICS_SOFT_SAMPLE_CAP=5000. The response is still
served in full so operators retain observability while being alerted
to fleet growth.

Adds prom-client@^15.1.3 and 17 new tests under
tests/charging-station/ui-server/UIMetricsEndpoint.test.ts covering
end-to-end exposition, security inheritance (access policy / rate
limit / basic-auth), the PII reject-list, exposition-format escaping
and the soft-cap warning. README.md updated with a "Metrics endpoint
(Prometheus)" subsection (config example, sample output, basic-auth
compatibility note, privacy/cardinality note, scrape_config snippet).

Refs: #851

* fix(ui-server): rebuild metrics registry on start to recover from stop/start cycle (M1)

The metrics registry was built only in the constructor and cleared on
stop(); after Bootstrap.stopUIServer() then startUIServer() (live-reload
path on uiServer.enabled toggle), metricsRegistry was undefined and
/metrics silently fell through to the JSON-RPC parser as a 400
Malformed URL with no log to indicate misconfiguration.

Move the registry build into start() guarded by an idempotency check
so the endpoint recovers across restart cycles. The constructor no
longer touches metricsRegistry.

Refs: #851 (Phase 4 review M1)

* feat(ui-server): expose metrics.softSampleCap in canonical defaults (M3)

The soft cardinality cap was documented as something operators may
'scale' but was not in the canonical defaults map. Add an optional
softSampleCap to UIServerMetricsConfigurationSchema (default 5000 via
METRICS_SOFT_SAMPLE_CAP) so operators can tune the threshold without
patching code, per AGENTS.md options-and-configuration rule.

Refs: #851 (Phase 4 review M3)

* fix(ui-server): serialize concurrent /metrics scrapes (M2 + m6 + m5)

Two concurrent GET /metrics requests interleaved on the shared
metricsSampleCount field and shared Gauge state, making the soft-cap
warning unreliable and the body content racy. Serialize scrapes via a
per-server promise chain; stop() now awaits the in-flight scrape
before clearing the registry, so a request mid-await no longer sees
undefined gauges.

Also adds the headersSent / writableEnded guard on the resolve path
so a client that closes mid-await does not trigger an emit-after-end.
The cap value is now read from uiServerConfiguration.metrics?.softSampleCap
(falls back to METRICS_SOFT_SAMPLE_CAP), wiring up the M3 schema field.

Refs: #851 (Phase 4 review M2 + m6 + m5)

* fix(ui-server): align iterateConnectors with simulator_station_connectors_total either-or (m2)

The per-connector iteration helper unconditionally yielded entries from
both data.connectors and data.evses, while simulator_station_connectors_total
uses an either-or rule. In current production these arrays are mutually
exclusive (buildConnectorEntries returns [] when hasEvses=true), so the
collision was unreachable, but the inconsistency made the helper fragile
to future invariant changes. Align it with the gauge's logic.

Refs: #851 (Phase 4 review m2)

* fix(ui-server): accept HEAD on /metrics for liveness-probe scrapers (n3)

Some Prometheus tooling HEAD-probes the endpoint before scraping.
Treat HEAD identically to GET in isMetricsRequest; Node drops the body
automatically on HEAD responses.

Refs: #851 (Phase 4 review n3)

* style: replace 'honoured' with 'honored' for spelling consistency (n2)

Aligns the new metrics docstring/log with American spelling used
elsewhere in the README and source.

Refs: #851 (Phase 4 review n2)

* docs(readme): fix metrics sample output to match actual exposition (m4)

The previous sample showed simulator_charging_stations_configured_total
with a 'template' label, but that metric is global with no labels.
The per-template counter is the separate simulator_template_configured
gauge. Replace the sample with the actual exposition shape.

Refs: #851 (Phase 4 review m4)

* test(ui-server): add wsState=undefined and EVSE-mode coverage (M4)

The new tests close two regression-detection gaps identified in Phase 4
review: (a) removing the !== undefined guard on data.wsState would
silently emit NaN — now caught by an absence assertion; (b) the OCPP
2.0.x code path (evses populated, connectors empty, evseStatus.connectors
Map iteration) was entirely untested — now exercised end-to-end.

Refs: #851 (Phase 4 review M4)

* test(ui-server): add soft-cap boundary tests for off-by-one detection (m3)

Probe-then-verify pattern: first scrape with a very high cap to count
actual samples produced; then assert no warn fires when cap equals the
sample count (strict-greater-than semantics) and one warn fires when
cap is one below. This regression test would fail if '>' became '>='
on the soft-cap comparison.

Refs: #851 (Phase 4 review m3)

* test(ui-server): retarget rate-limit burst at allowed loopback path (m1)

T9 previously hit a non-loopback denied path, exercising the rate
limiter on a 403 response rather than on /metrics. Switch to a
loopback request that is allowed through the access policy and assert
that the rate limit fires on /metrics directly.

Refs: #851 (Phase 4 review m1)

* style(test): rename tests per TEST_STYLE_GUIDE and drop redundant assertion (n1, n4)

Renamed all tests from 'T<N>: <verb>...' to 'should <verb>...' format
per tests/TEST_STYLE_GUIDE.md. Also dropped the redundant pre-stop
assertion in 'should clear registry on stop()' (the post-stop
assertion already proves the stop() effect).

Refs: #851 (Phase 4 review n1, n4)

* refactor(ui-server): add HEAD to HttpMethod enum (n1)

Avoid the bare 'HEAD' string literal in UIHttpServer.isMetricsRequest by
extending HttpMethod with an explicit HEAD member, matching the AGENTS.md
guidance to prefer enumerations over string literals when one exists.

* refactor(ui-server): adopt prom-client canonical collect pattern (M1+m1+m2+m3+n2+n3+n4+n5+n6)

Phase 6 review feedback consolidated into one coherent surgery on the
metrics path:

- M1: defineGauge now returns Gauge<L> with auto-injected registers and a
  string-literal label-name generic. Every collect() callback is non-arrow
  with typed 'this: Gauge<L>', per the prom-client documentation. Eliminates
  ~20 unsafe 'as Gauge | undefined' casts and ~30 dead null-guards.
- m1: collapse the four global aggregate gauges into a single tuple-loop,
  mirroring the per-template loop pattern.
- m2: append a terminal '.catch(() => undefined)' to the metricsScrapeChain
  reassignment in stop() so the field always points to a handled promise.
- m3: document the metricsSampleCount/metricsScrapeChain concurrency
  invariants; explicitly forbid async collect callbacks.
- n2: factor 'ChargingStationDataProvider' type alias at module scope.
- n3: drop the redundant outer 'async' on handleMetricsRequest.
- n4: replace box-drawing dividers with plain JSDoc section markers.
- n5: rename 'PII whitelist invariant' to 'PII allowlist invariant'.
- n6: document the OCPP either-or rule on iterateConnectors.

The HTTP /metrics contract is unchanged; existing tests pass without
modification.

* docs(readme): align metrics documentation with code (m4, m5)

- m4: replace the two stale '# HELP' lines in the sample exposition block
  with the strings actually emitted by the registry (commit 1bac5c23d
  fixed two of the four; the other two had drifted again).
- m5: add the 'metrics' sub-key to the uiServer row of the configuration
  table: bullet under the Description column documenting metrics.enabled
  and metrics.softSampleCap, and corresponding shape in the Value type
  column. AGENTS.md 'Documentation conventions / Exhaustivity' treats the
  table as the authoritative tunable list.

* test(ui-server): regression for concurrent /metrics scrapes (R1, R2)

Locks the metricsScrapeChain serialization invariant against future drift:
- R1: two concurrent scrapes against a registry whose sample count equals
  the configured softSampleCap; honest serialized execution produces zero
  warns. A regression that removes the chain (or makes any collect()
  callback async) would either spuriously warn (counter shared between
  scrapes) or corrupt the body, both detected by this test.
- R2: same test indirectly guards against async collect callbacks; the
  invariant is also documented as a JSDoc on buildMetricsRegistry.

* refactor(ui-server): apply Phase 7 polish (M1+n2+n3+n4+n5+n6+n8+n9)

- M1: collapse 3 inline per-station gauges (ws_state, connectors_total,
  boot_status_info) into existing helpers (~40 LOC less duplication).
- n2: simulator_station_connectors_total now counts via iterateConnectors,
  making the iterateConnectors JSDoc 'shared with' claim literally true.
- n3: replace 'const self = this' with a typed 'provider:
  IChargingStationDataProvider' built from .bind(this) method captures.
  Drops the @typescript-eslint/no-this-alias suppression and narrows the
  type surface accessed by collect() callbacks.
- n4: justify defineGauge<L extends string = never> default in the JSDoc
  (stricter than prom-client's 'Gauge<T extends string = string>').
- n5: addPerStationInfoLabel hard-codes 'status' (all callers used it);
  addConnectorOneHot narrows labelName to a literal union of valid keys —
  both eliminate the theoretical 'hash_id'/'connector_id' clobber risk.
- n6: rename ChargingStationDataProvider to IChargingStationDataProvider
  (interface form, repo I-prefix convention) and extend with
  getChargingStationsCount.
- n8: tighten handleMetricsRequest and iterateConnectors @param
  descriptions to remove low-signal restatements.
- n9: drop the explanatory '// Explicit return required by
  promise/always-return lint rule' comment; the lint rule speaks for
  itself when the line is removed.

* test(ui-server): tighten concurrent-scrape regression with sample-count assertion (n1)

Replace the weak 'bodyA === bodyB' check (which would pass even if the
metricsScrapeChain serialization were removed, since both scrapes
collect against the same Registry instance) with an exact sample-line
count assertion against the probed value. Locks two distinct invariants
that bodyA===bodyB did not: no truncation, and no double-count from
interleaved 'collect()' calls under prom-client's internal Promise.all.

* docs(readme): add trailing semicolon to metrics value type (n7)

Align the new 'metrics?: { ... }' member of the uiServer Value type
column with the surrounding style (every other nested-object member
in the same type literal terminates with ';').

* [autofix.ci] apply automated fixes

* refactor(ui-server): apply Phase 8 NITs (n2+n3+n4+n5+n7; n1 deferred)

- n2: rename addPerStationInfoLabel to addPerStationStatusInfo to match
  the helper's actual responsibility (it hard-codes the 'status' label
  per Phase 7 n5).
- n3: drop the I-prefix on ChargingStationDataProvider — most interfaces
  in src/ are unprefixed, only IBootstrap uses I.
- n4: extend the ChargingStationDataProvider JSDoc to acknowledge that
  the inline simulator_ui_server_known_stations_total gauge consumes the
  getChargingStationsCount method (not only the helpers).
- n5: extract countConnectors as a single source of truth for the OCPP
  either-or rule and have simulator_station_connectors_total consume it.
  Also restructure the iterateConnectors JSDoc to cross-reference
  countConnectors instead of the gauge.
- n7: tighten @param res on handleMetricsRequest to 'HTTP response to
  end with the exposition body.' (restores the direction Phase 7 n8
  shortened away).
- n1 (addConnectorOneHot generic threading) is deferred: TypeScript
  cannot narrow the dynamic computed property '[labelName]: v' to
  'Record<L, string>' without an 'as' cast, which AGENTS.md 'Type
  safety' forbids. The runtime literal-union on labelName is kept as
  the type-safety contract; a 3-line comment documents the trade-off.

* refactor(ui-server): apply Phase 9 NITs (n1+n2+n3)

- n1: thread Gauge<...> end-to-end on addConnectorOneHot via a
  ConnectorOneHotLabel type + typed-init + property mutation pattern.
  Phase 9 oracle B proved the cast-free pattern compiles cleanly under
  strict TS 6.0.3 (probe with 8 alternatives, only this one passes).
  Replaces the Phase 8 deferral comment, which mis-attributed an 'as'
  ban to AGENTS.md (AGENTS.md only forbids '!' and 'any').
- n2: drop the self-{@link} on countConnectors JSDoc opening — the
  block documents countConnectors itself, so the cross-reference back
  to it reads awkwardly. Asymmetric pattern with iterateConnectors
  preserved.
- n3: drop the duplicate '(see {@link countConnectors} for the
  invariant source)' parenthetical on iterateConnectors JSDoc — the
  leading 'same...rule as {@link countConnectors}' already directs the
  reader, and 'invariant source' was jargon.

* docs(ui-server): harmonize JSDoc prose with repo cadence

Three Phase 9-pass-2 audit findings against the rest of the codebase:

- Drop the coined phrases 'either-or rule' and 'OCPP-version-driven'
  from countConnectors and iterateConnectors JSDoc; the repo-wide
  prose simply names 'OCPP 1.6' / 'OCPP 2.0.x' inline (see ChargingStation.ts
  '$OCPP 2.0.1 §4.2.3', Helpers.ts 'OCPP 2.0 chargingSchedule',
  TemplateSchema.ts 'OCPP 2.0.1 §7.2'). Use 'mutually exclusive' for the
  source-split semantic.
- Drop the bold-stress '**Invariant**:' prose markers from the
  metricsSampleCount, stop() and buildMetricsRegistry JSDoc; `grep -rn '\*\*'
  src/` returns 0 such markers in JSDoc anywhere else. Inline the
  invariant prose into the surrounding sentences without a section
  marker.

* docs(test): harmonize 'soft cap' spelling and @description cadence

Two outliers found in the test file's prose during repo-wide audit:
- 'soft-cap' (3 occurrences: @description, 1 test name, 1 inline comment)
  was hyphenated while the production warning string and ConfigurationSchema
  JSDoc both spell it 'soft cap' / 'soft sample cap' (no hyphen). Tests'
  string-includes('soft cap') matches were already correct; only the prose
  drifted.
- @description was multi-line whereas every other test file in tests/
  uses a single-line @description (verified across ChargingStation*.test.ts,
  ConfigurationKeyUtils.test.ts, TemplateValidation.test.ts, etc.).

* docs(ui-server): harmonize misconfiguration warning style with repo cadence

The warnIfMisconfigured warning used an em-dash + uppercase 'NOT' to
separate the condition from the consequence:

  metrics.enabled=true is only honored when uiServer.type='http';
  current type='X' — the /metrics endpoint will NOT be served.

Sibling warnings in AbstractUIServer (host-not-allowed and tls-required
at lines 441-457) use semicolon-and-period separators with normal-case
prose and a final action sentence. Match that cadence:

  metrics.enabled=true is honored only when uiServer.type='http'; current
  type='X'. The /metrics endpoint will not be served. Set uiServer.type='http'
  to expose metrics.

Test substring matches ('metrics' / 'http') are preserved.

* test(ui-server): rename M-prefix fixtures to T-prefix convention

The fixture station hash IDs 'station-M{evse,boundary,concurrent}' inherited
the 'M' prefix from the Phase 4 review's MAJOR finding IDs (M1..M4) and
were tokenized by cspell as unknown words 'Mevse', 'Mboundary',
'Mconcurrent' — emitting 7 lint warnings.

The rest of the metrics test file already follows the 'station-T<n>'
numeric convention ('station-T5', 'station-T12', 'station-T13',
'station-T16'), where <n> matches the test ordinal. Map the M-prefixed
fixtures to their actual test ordinals:
- 'Mevse'        -> 'T18' (EVSE-mode test, the 18th 'await it()')
- 'Mboundary-*'  -> 'T19-*' (soft-cap boundary, 19th)
- 'Mconcurrent-*'-> 'T20-*' (concurrent-scrape regression, 20th)

Quality gates now report 0 errors AND 0 warnings; previous baseline
was 0 errors / 7 warnings.

* docs(readme): drop redundant Metrics endpoint section, harmonize uiServer.metrics bullet

The dedicated '### Metrics endpoint (Prometheus)' section duplicated
information that is already authoritative elsewhere:
- The /metrics behavior, configuration shape and defaults are documented
  in the uiServer row of the configuration table (single source of
  truth, per AGENTS.md 'No duplication' rule).
- The PII reject-list and softSampleCap semantics are documented in
  UIServerMetricsConfigurationSchema JSDoc.
- The HTTP-only constraint and the warning behavior are documented in
  the AbstractUIServer.warnIfMisconfigured warning string itself.

Drop the section, the TOC entry, and the now-dangling cross-link from
the table bullet. Restructure the _metrics_ bullet to mirror the
_accessPolicy_ cadence in the same row (top-level intro + indented
sub-bullets per sub-key), so the table description is self-sufficient
and harmonized with its sibling.

* chore(gitignore): ignore .codegraph alongside .omo/

.codegraph is a symlink to .omo/codegraph/projects/<hash>/ used by the
oh-my-opencode tooling, on the same lifecycle as .omo/ and .sisyphus/.
Group it under the existing 'oh-my-opencode' section.

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
6 weeks agofix(deps): update all non-major dependencies (#1915)
renovate[bot] [Sat, 20 Jun 2026 12:00:45 +0000 (14:00 +0200)] 
fix(deps): update all non-major dependencies (#1915)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
6 weeks agochore(deps): update actions/checkout action to v7 (#1914)
renovate[bot] [Fri, 19 Jun 2026 17:59:00 +0000 (19:59 +0200)] 
chore(deps): update actions/checkout action to v7 (#1914)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
6 weeks agochore(deps): update all non-major dependencies (#1913)
renovate[bot] [Fri, 19 Jun 2026 13:10:46 +0000 (15:10 +0200)] 
chore(deps): update all non-major dependencies (#1913)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
6 weeks agostyle(test): fix lint warnings in OCPP 2.0 ForceTxOnInvalid test
Jérôme Benoit [Wed, 17 Jun 2026 17:22:45 +0000 (19:22 +0200)] 
style(test): fix lint warnings in OCPP 2.0 ForceTxOnInvalid test

Three pre-existing post-merge warnings introduced by the squash-merge
of #1907 on the file:
`tests/charging-station/ocpp/2.0/OCPP20ResponseService-ForceTxOnInvalid.test.ts`

- Lines 67-68: `jsdoc/require-param-description` — fill the empty
  `@param idToken.idToken` and `@param idToken.type` sub-property
  descriptions auto-inserted by eslint-plugin-jsdoc.
- Line 298: `@cspell/spellchecker` — replace "remock" with "re-mock"
  (English-correct hyphenation, matches existing repo idioms like
  "re-entrant", "re-export").

Verification: pnpm lint exit 0 with 0 errors / 0 warnings; focused
`pnpm test` on the file: 16/16 GREEN (substring assertions unaffected).

6 weeks agofeat(simulator): add forceTransactionOnInvalidIdToken template flag (#1907)
Jérôme Benoit [Wed, 17 Jun 2026 16:49:22 +0000 (18:49 +0200)] 
feat(simulator): add forceTransactionOnInvalidIdToken template flag (#1907)

* feat(simulator): add forceTransactionOnInvalidIdToken template flag

Allow station-initiated transactions to continue even when CSMS replies
with a non-Accepted IdToken status (OCPP 1.6 idTagInfo.status, OCPP 2.0.1
idTokenInfo.status). Default false preserves spec-compliant behavior.

The flag is non-spec-compliant by design (violates OCPP 2.0.1 E05.FR.09,
E05.FR.10, E06.FR.04 when enabled) and is intended for testing edge-case
charging station implementations that ignore CSMS rejection. JSDoc on the
field warns explicitly; README entry flags it as a non-spec-compliant test
override.

Scope in OCPP 2.0.1 is limited to TransactionEvent eventType=Started;
mid-transaction revocation (Updated/Ended event types) still triggers
deauthorization regardless of the flag. The OCPP 2.0.1 \`StopTxOnInvalidId\`
device-model variable is left untouched — the template flag short-circuits
ahead of the variable's accounting branch.

Tests: 16 new test cases across both OCPP namespaces covering force-on/off,
mid-tx revocation preservation, override marker log presence, auth cache
non-relaxation, pre-Start guard preservation, and full status-enum parity
(8 OCPP 2.0.1 statuses).

Closes #1826

* fix(types): restore fixedName and pin endedConnector non-null

The forceTransactionOnInvalidIdToken JSDoc rewrite in 73e3358a accidentally
deleted the adjacent fixedName?: boolean field on ChargingStationTemplate,
breaking 30+ TS errors in CI (ChargingStationNameTemplate Pick keyed on
fixedName). Local pnpm test does not run tsc --noEmit, so the regression
was invisible until CI.

Also fix a CI-only error in OCPP20ResponseService-ForceTxOnInvalid.test.ts:
endedConnector is typed as ConnectorStatus | undefined; replace the
optional-chain assertions with an explicit if/fail guard so TS narrows
the type for the subsequent strict-equal assertions.

* test(2.0): document deferred MV-pump fence and ConcurrentTx scope

Phase-4 review-C MINOR-1: the 2.0-T2 test asserts that
\`startUpdatedMeterValues\` is called but stubs the helper, so a wire-level
regression where the interval binds to the wrong connector would not be
caught. Phase 6 golden-set with the live mock CSMS will close that gap;
add a TODO comment to make the deferral explicit.

Phase-4 review-C NIT-1: the T7 enum-parity loop omits ConcurrentTx; this
is correct (ConcurrentTx is not an IdToken rejection in OCPP 2.0.1) but
deserves an inline comment to prevent a future contributor from adding it
naively.

* test(ocpp): tighten forceTransactionOnInvalidIdToken coverage and clarify docs

Follow-up to #1907 applying post-review fixes from a 3-angle audit
(production / types-schema-readme / tests-adversarial).

Production (OCPP 2.0):
- Simplify defensive `else if (overrideRejection && status !== Accepted)`
  to `else if (overrideRejection)` in handleResponseTransactionEvent;
  the second predicate is entailed by the enclosing if/else.
- Trim duplicated 5-line comment block down to a 2-line pointer to the
  canonical JSDoc on ChargingStationTemplate.forceTransactionOnInvalidIdToken.

Documentation:
- ChargingStationTemplate JSDoc: disambiguate from the OCPP variables
  StopTransactionOnInvalidId (1.6) and StopTxOnInvalidId (2.0.1) which
  control mid-transaction stop on revocation and have inverse polarity;
  state independence from ocppStrictCompliance.
- README row: same disambiguation appended to the cell.

Tests (OCPP 1.6 ForceTxOnInvalid):
- T5 (pre-Start guard): change response status from Accepted to Invalid
  so the test exercises the flag-vs-guard interaction it claims to lock.
- T6 (new): status-enum parity loop via Object.values(OCPP16AuthorizationStatus)
  excluding Accepted and ConcurrentTx (3 instances: Blocked, Expired, Invalid).
- T2: replicate the Phase-6 fake-timer-fence TODO from the sibling 2.0 file
  (MV pump observability disclosure).
- Rename test titles to should [verb] per tests/TEST_STYLE_GUIDE.md \xa71.

Tests (OCPP 2.0 ForceTxOnInvalid):
- buildTransactionEventRequest: add optional idToken parameter to exercise
  the auth-cache update path at handleResponseTransactionEvent.
- T8 (new): C10.FR.01/04/05 auth-cache invariant test, parametric over
  [flag=true, flag=false] (2 instances) asserting updateAuthorizationCache
  is called with the supplied idToken and the CSMS-replied idTokenInfo.
- T4 (Ended): add deauth call-count assertion (=== 0); locks the
  cleanup-runs-before-deauth-gate ordering invariant. The deauth path is
  reached but no-ops because cleanupEndedTransaction clears transactionId
  first, so getConnectorIdByTransactionId returns null. A regression that
  reorders cleanup after the gate (or preserves transactionId on Ended)
  flips this to 1.
- T7 (parity): replace static 8-element array with
  Object.values(OCPP20AuthorizationStatusEnumType).filter(...). Same 8
  instances today; future enum additions are auto-covered.
- T6: split into 2 it() blocks (flag-on / flag-off) eliminating the
  mid-test standardCleanup+remock pattern. Each block additionally asserts
  that the override-marker warn-log is NOT emitted on null idTokenInfo,
  locking the invariant against an A6-style regression where the
  override-marker else-if is hoisted outside the outer null guard.
- Rename test titles to should [verb].

Verification:
- pnpm typecheck exit 0
- pnpm lint exit 0
- pnpm test: 0 fail across the full suite
- 23/23 GREEN on the two ForceTxOnInvalid files (was 17/17; +6 new)
- Three mutation experiments confirm regression-detection strength:
  * Drop `|| forceTransactionOnInvalidIdToken` from OCPP16 gate (line 427):
    T2 + 3 parity tests fail with `false !== true` on transactionStarted.
  * Replace `if (requestPayload.idToken != null)` with `if (false)` in
    OCPP20 cache update (line 519): T8 (both flag states) fails `0 !== 1`.
  * Bypass the OCPP16 unauthorized-remote-start guard (lines 315-329):
    T5 only fails (regression-localized to that scenario).

* docs(ocpp): note ocppStrictCompliance independence and clarify Started log

Follow-up to review v2 of #1907 closing the two actionable findings
(B4 Low, N1 nit). Two remaining v2 nits (B5 multi-line JSDoc, R3 test
cast) are intentionally deferred — rationale in
/tmp/pr-1907-review-v2/fixes-design.md.

B4 — ChargingStationTemplate.forceTransactionOnInvalidIdToken JSDoc and
the matching README row both gain a one-clause disambiguation: "Independent
of `ocppStrictCompliance` (operates on response handling, not schema
validation)". Mirrors the pattern used by the sibling `outOfOrderEndMeterValues`
row (which cites ITS `ocppStrictCompliance` coupling), preventing readers
from inferring a non-existent coupling for the new flag.

N1 — OCPP20ResponseService.handleResponseTransactionEvent override warn
log now reads "...on eventType=Started despite..." (was "...on Started
despite..."). The `eventType=` prefix removes ambiguity with OCPP
connector state names (Charging, Available) and aligns the log vocabulary
with the JSDoc, the inline reference comment, and the test fixture
(`requestPayload.eventType === OCPP20TransactionEventEnumType.Started`).
The override-marker substring `forceTransactionOnInvalidIdToken=true` is
preserved verbatim, so the four test assertions that grep for it remain
unaffected.

Verification:
- pnpm typecheck exit 0
- pnpm lint exit 0
- pnpm test: 0 fail across the full suite
- 23/23 GREEN on the two ForceTxOnInvalid files (count unchanged; doc-only changes)
- README table re-aligned by prettier on save (column padding shifted
  421 -> 460 chars to fit the longer description); diff is mechanical
  whitespace, content change is one clause.

6 weeks agorefactor(bootstrap): remove redundant stopping flag (#1909)
Jérôme Benoit [Wed, 17 Jun 2026 16:49:03 +0000 (18:49 +0200)] 
refactor(bootstrap): remove redundant stopping flag (#1909)

6 weeks agochore(deps): update all non-major dependencies to ^3.3.5 (#1910)
renovate[bot] [Wed, 17 Jun 2026 08:56:40 +0000 (10:56 +0200)] 
chore(deps): update all non-major dependencies to ^3.3.5 (#1910)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>