Vitor Coelho [Fri, 30 Oct 2026 16:23:20 +0000 (16:23 +0000)]
fix(simulator): reject a performance storage URI that is not an absolute URL (#2169)
* fix(simulator): reject a performance storage URI that is not an absolute URL
performanceStorage.uri (and its deprecated URI alias) accepted any
string, but the simulator parses it with new URL() while building the
configuration at module load and in the Storage constructor. A value
such as "not a url" or a relative path like "performance/records.json"
therefore crashed the simulator with a bare "TypeError: Invalid URL"
that did not name the field.
Validate the value with URL.canParse() in the configuration schema so
it is reported as a configuration error on performanceStorage.uri (or
performanceStorage.URI) instead. The narrowing is folded into the
unreleased configuration schema version 2.
Signed-off-by: VitorCoelho <vitorc195@gmail.com>
* fix(simulator): validate performanceStorage.uri only where the simulator parses it
The simulator parses the URI at load time for the jsonfile and sqlite
storage types and when it opens an enabled storage. Values the simulator
never reads, including the deprecated URI alias, are left unvalidated so
existing configurations keep loading.
Signed-off-by: VitorCoelho <vitorc195@gmail.com>
* test(utils): cover performanceStorage.uri only where the simulator parses it
Signed-off-by: VitorCoelho <vitorc195@gmail.com>
* fix(simulator): validate performanceStorage.uri for remote backends with enabled omitted
The mongodb, mysql and mariadb backends inherit 'enabled: true' from the
default storage configuration, so the storage is opened and the URI parsed
unless 'enabled' is false. Treat an omitted 'enabled' as enabled for them.
mongodb, mysql and mariadb sections without 'enabled' now reject a
relative uri; sections with 'enabled: false' and a bare '{ enabled: true }'
section are still accepted.
Signed-off-by: VitorCoelho <vitorc195@gmail.com>
* refactor(simulator): share the performance storage enabled default with the schema
The schema resolved an omitted 'performanceStorage.enabled' with
'enabled !== false', which re-encodes the 'enabled: true' default of
defaultStorageConfiguration in Configuration.ts. Move that boolean to
Constants.DEFAULT_PERFORMANCE_STORAGE_ENABLED, use it in the runtime default
and resolve the schema fallback with 'value.enabled ?? default', so a change
to the canonical default is followed by validation. No behaviour change.
Vitor Coelho [Fri, 30 Oct 2026 16:23:16 +0000 (16:23 +0000)]
fix(ocpp): reject a supervision URL key value the ws client cannot dial in OCPP 1.6 ChangeConfiguration (#2159)
* Add validation for supervision URL in OCPP16 service
Reject invalid supervision URL values to prevent reconnect failures.
* Add tests for supervision URL OCPP configuration key
* fix(ocpp): also reject supervision URL schemes the ws client cannot dial
URL.canParse accepted values such as ftp://host, which were stored
and then failed on reconnect. Accept only the schemes the ws client
dials: ws, wss, http, https and ws+unix.
Signed-off-by: VitorCoelho <vitorc195@gmail.com>
* fix(ocpp): reject a supervision URL with a fragment or an empty ws+unix socket path
The ws client throws for a URL with a fragment identifier and for a
ws+unix URL without a pathname, so OCPP 1.6 ChangeConfiguration accepted
and stored values that fail on reconnect. Reject them.
Signed-off-by: VitorCoelho <vitorc195@gmail.com>
* test(ocpp): cover fragments and ws+unix socket paths in OCPP 1.6 ChangeConfiguration
Signed-off-by: VitorCoelho <vitorc195@gmail.com>
* fix(ocpp): reject an empty fragment and a ws+unix URL without a request path separator
The charging station appends its identity to the supervision URL. After a
trailing '#' the identity becomes a fragment, which the ws client rejects,
and a ws+unix URL without a ':' between the socket path and the request
path gets the identity appended to the socket path, so the connection
fails. Reject both in the OCPP 1.6 ChangeConfiguration guard.
Signed-off-by: VitorCoelho <vitorc195@gmail.com>
* test(ocpp): cover an empty fragment and ws+unix URLs without a request path separator
Signed-off-by: VitorCoelho <vitorc195@gmail.com>
* fix(ocpp): reject supervision URL key values with whitespace, a query string or an ambiguous ws+unix separator
The OCPP 1.6 ChangeConfiguration value is stored as sent, but the guard
validated the parsed URL, which strips surrounding whitespace, tabs and
newlines, so 'ws://localhost:8888 ' was accepted and the reconnect then
failed. The station identity is appended to the URL, so after a '?' it
lands in the query, and ws keeps only the first two ':'-separated parts
of a ws+unix pathname. Reject these; the query and ws+unix rules match
SupervisionUrlSchema.
Signed-off-by: VitorCoelho <vitorc195@gmail.com>
* test(ocpp): cover whitespace, query strings and ws+unix separators in the supervision URL key
Signed-off-by: VitorCoelho <vitorc195@gmail.com>
* fix(ocpp): accept one-character ws+unix socket paths and require a '/' request path
A relative socket path such as 's' in 'ws+unix:s:/OCPP16' is valid, so
require a socket path other than '/' instead of two characters. A request
path without a leading '/' (ws+unix:s:OCPP16) makes the server answer 400,
so require it to be empty or to start with '/'. Same rule as
SupervisionUrlSchema in #2168.
Signed-off-by: VitorCoelho <vitorc195@gmail.com>
* test(ocpp): cover one-character ws+unix socket paths and request paths without '/'
Regressions for the supervision URL key fix: 'ws+unix:s:/OCPP16' and
'ws+unix:./s:/OCPP16' are accepted, 'ws+unix:s:OCPP16' and
'ws+unix:///tmp/simulator.sock:OCPP16' are rejected.
Signed-off-by: VitorCoelho <vitorc195@gmail.com>
* refactor(ocpp): validate the supervision URL key with the shared SupervisionUrlSchema
The OCPP 1.6 ChangeConfiguration guard duplicated the supervision endpoint
policy (schemes, '#' and '?' delimiters, ws+unix socket and request path)
that SupervisionUrlSchema owns since #2160 and #2168. Consume the shared
schema and keep only the OCPP-specific part locally: the key stores the raw
value, so whitespace and control characters are rejected before the schema
check, which would otherwise strip them. Accepted values are still stored
verbatim. No behaviour change.
Vitor Coelho [Fri, 30 Oct 2026 14:59:19 +0000 (14:59 +0000)]
fix(simulator): reject supervision URLs that break once the station identity is appended (#2168)
* fix(simulator): reject supervision URLs that break once the station identity is appended
The charging station dials the supervision URL with its identity appended.
After a trailing '#' the identity becomes a fragment, which the ws client
rejects, and a ws+unix URL without a ':' between the socket path and the
request path gets the identity appended to the socket path, so the
connection fails (ENOTDIR). Reject both in SupervisionUrlSchema.
Signed-off-by: VitorCoelho <vitorc195@gmail.com>
* test(utils): check the supervision URL schema against the URL the station dials
Signed-off-by: VitorCoelho <vitorc195@gmail.com>
* docs(readme): document the ws+unix supervision URL form
Signed-off-by: VitorCoelho <vitorc195@gmail.com>
* fix(simulator): reject supervision URLs with a query string or an ambiguous ws+unix separator
The charging station identity is appended to the supervision URL, so after
a '?' it lands in the query (ws://h/ocpp?a=b dials /ocpp with query
'a=b/CS-1'). ws keeps only the first two ':'-separated parts of a ws+unix
pathname, so 'ws+unix:///s.sock:/oc:pp' dials /oc, and 'ws+unix:///:/ocpp'
dials the socket '/'. Reject these, checked against real TCP and Unix-socket
servers.
Signed-off-by: VitorCoelho <vitorc195@gmail.com>
* test(utils): cover query strings and extra ws+unix separators in supervision URLs
Signed-off-by: VitorCoelho <vitorc195@gmail.com>
* docs(readme): document the query string and ws+unix separator rules for supervision URLs
Signed-off-by: VitorCoelho <vitorc195@gmail.com>
* fix(simulator): accept one-character ws+unix socket paths and require a '/' request path
A relative socket path such as 's' in 'ws+unix:s:/OCPP16' is valid, so
require a socket path other than '/' instead of two characters. A request
path without a leading '/' (ws+unix:s:OCPP16) makes the server answer 400,
so require it to be empty or to start with '/'.
Signed-off-by: VitorCoelho <vitorc195@gmail.com>
* test(utils): cover one-character ws+unix socket paths and request paths without '/'
Regressions for the SupervisionUrlSchema fix: 'ws+unix:s:/OCPP16' and
'ws+unix:./s:/OCPP16' are accepted, 'ws+unix:s:OCPP16' and
'ws+unix:///tmp/simulator.sock:OCPP16' are rejected.
Jérôme Benoit [Thu, 29 Oct 2026 23:43:13 +0000 (00:43 +0100)]
refactor(charging-station): config assets single source of truth (#2157)
* refactor(charging-station): config assets single source of truth
Replace every join(dirname(fileURLToPath(import.meta.url)), "assets", ...)
asset path with a single resolveAssetsDir() helper in src/resolveAssetsDir.ts.
It honors the EVSE_ASSETS_DIR override, otherwise resolves the assets folder
that sits beside the helper - dist/assets in the bundle, src/assets via tsx.
- Configuration.ts, HelpersConfig.ts (templates, id-tags, EV profiles),
Bootstrap.ts (assetsDir) and UIMCPServer.ts (schema dir) now use it.
- start:dev(+debug/prof) run src/start.ts through tsx with no build step, so
dev reads and reloads src/assets directly; production start stays bundled.
- README documents editing src/assets (dist is a build artifact) plus override.
Configuration defaults are preserved: src/assets/config.json is absent from
sources, so the runtime still falls back to the default configuration.
* [autofix.ci] apply automated fixes
* refactor(charging-station): route remaining schema loaders and config diagnostics through resolveAssetsDir
Address PR review: MCPResources.getSchemaBaseDir and OCPPServiceUtils.parseJsonSchemaFile now resolve via resolveAssetsDir() (honoring EVSE_ASSETS_DIR); drop the assets/ prefix from createPayloadOptions so relativePath is relative to the assets dir. The missing-config error now interpolates the resolved configurationFile path.
* [autofix.ci] apply automated fixes
* test(charging-station): add unit tests for resolveAssetsDir assets-dir resolver
Add a unit test for the single source of truth that resolves the assets
directory (EVSE_ASSETS_DIR override; absolute paths used as-is; relative
paths resolved against cwd; module-side fallback). It locks the documented
absolute-used-as-is contract and closes the coverage gap on the resolver.
* chore(charging-station): gitignore dev-generated station config state
Dev mode now resolves assets from src/assets (single source of truth), so the simulator's provisioned station configs and .simulator-state.json are written to src/assets/configurations/ at runtime. Previously (bundle dev) this state landed in dist/assets (already ignored). Ignore the generated directory to keep the working tree clean.
* fix(charging-station): route cert-manager base path via resolveAssetsDir; qualify README asset paths
Review follow-up (MEDIUM findings):
- OCPP20CertificateManager: BASE_CERT_PATH now join(resolveAssetsDir(),
'configurations') instead of hardcoded 'dist/assets/configurations'
(which resolved to a nonexistent directory in tsx/dev/tests mode).
Certificate-manager test cleanup paths now use resolveAssetsDir() too.
- README: charging-station configuration paths now expressed as
<assets-dir>/configurations, where <assets-dir> is dist/assets in
production and src/assets in development (locations, hierarchy, section
header), so developers no longer look in dist/ in dev mode.
* test(assets): relocate resolveAssetsDir test to mirror source tree
resolveAssetsDir.ts lives at the src/ root (required by its
<module-dir>/assets resolution), so its test belongs at the tests/
root, not tests/utils/. Move tests/utils/resolveAssetsDir.test.ts to
tests/resolveAssetsDir.test.ts and adjust the relative import depth.
* docs(assets): make JSDoc asset-path references mode-neutral
Cross-validation of the asset-centralization review findings surfaced
JSDoc comments still assuming the production layout. Express them in
mode-neutral terms consistent with resolveAssetsDir():
- HelpersConfig: drop "bundled" from the id-tags/ev-profiles resolvers;
buildTemplateName references the station-templates subdirectory of the
assets directory.
- Statistics: template-index reconstruction path now
<assets-dir>/configurations/ instead of dist/assets/configurations/.
* test(assets): make path assertions platform-robust (Windows)
Asset-path centralization (resolveAssetsDir) makes asset paths absolute,
which exposed POSIX-only path handling in two tests on Windows:
- OCPP20CertificateManager: the serial-sanitization test parsed the cert
path with split('/').pop(), which on Windows returns the whole
drive-qualified path (contains ':'); use path.basename() instead.
- resolveAssetsDir: the absolute-override tests used a POSIX-only
'/tmp/...' fixture that isAbsolute() rejects on Windows; build the
fixture with path.resolve(cwd, ...) so it is absolute on every platform.
* fix(assets): address PR #2157 review findings
- hermetic tests: tests/stubs/assets fixture (junctions to read-only src/assets sub-trees + writable configurations/) wired via EVSE_ASSETS_DIR in test scripts
- centralize OCPP schema base dir (getOcppSchemaBaseDir) used by MCPResources, UIMCPServer, OCPPServiceUtils
- isPathWithinBase: platform-safe sep-based prefix guard
- resolveAssetsDir tests: strict module-relative assertions
- README: EVSE_ASSETS_DIR layout/writability/pre-start, prod/dev asset-dir mapping, persistState generation
- parseJsonSchemaFile: document SyntaxError throw
* [autofix.ci] apply automated fixes
* docs: relocate EVSE_ASSETS_DIR contract from README to resolveAssetsDir JSDoc
The README is user-facing documentation; the expected asset layout,
writability requirement, and set-before-start timing for the
EVSE_ASSETS_DIR override are developer-facing details. Document the
full contract in the resolveAssetsDir.ts module JSDoc instead, and
keep the README limited to the user workflow (edit src/assets, dist
rebuilt by pnpm build, configurations/ persists across builds).
* test: run the suite hermetically against the assets fixture
The suite asserted configuration defaults while reading whatever
resolveAssetsDir() found: pnpm test from the repo root worked, but
standalone runs (node --import tsx --test <file>) hit the
user-generated src/assets content — a user config.json failed the
suite, and the certificate-manager test wrote into
src/assets/configurations.
- src/resolveAssetsDir.ts: fail fast with AssetsDirError when the
EVSE_ASSETS_DIR override points to a missing or non-directory path
(the <module-dir>/assets fallback stays the trusted default);
export OCPP_SCHEMA_DIR (<assets-dir>/json-schemas/ocpp)
- OCPP 1.6 / 2.0 payload validators use OCPP_SCHEMA_DIR instead of
hardcoded relative strings
- tests/stubs/testEnvBootstrap.mjs (new): node --import module for the
test scripts — sets EVSE_ASSETS_DIR to the absolute fixture path
(honoring an explicit user override), NODE_ENV=test, and ensures the
fixture layout
- tests/stubs/setupAssetsFixture.mjs: split into a non-destructive,
concurrency-safe ensureAssetsFixture() and the direct-entry
full-regeneration setupAssetsFixture()
- package.json: test / test:debug / test:coverage load the bootstrap
via --import instead of a cwd-relative cross-env EVSE_ASSETS_DIR
- Configuration.test.ts and OCPP20CertificateManager.test.ts import the
bootstrap first: standalone runs of these files are hermetic too
- tests/resolveAssetsDir.test.ts: real temp dirs (mkdtemp) instead of
relative paths never created on disk; AssetsDirError tests
- README: the <assets-dir>/configurations note now matches the runtime
(mkdir recursive on write, not a startup generation); idTagsFile /
evProfilesFile documented relative to <assets-dir> instead of
src/assets
* fix: restore --test-reporter-destination in the test:coverage script
The script rewrite accidentally renamed the longstanding
--test-reporter-destination flag to --test-coverage-destination,
which Node 22.x/24.x do not know (CI failed with
'node: bad option: --test-coverage-destination=coverage/lcov.info').
Verified: the exact script runs on Node 22.23.4 and 26.12.1 and
produces a populated lcov file.
* refactor: move AssetsDirError to src/exception per review findings
- Relocate AssetsDirError to src/exception/AssetsDirError.ts to follow the
repository error-class convention (BaseError, OCPPError, TimeoutError +
barrel); re-export it from resolveAssetsDir.ts so the module API is
unchanged.
- Correct the assertAssetsDir JSDoc: call sites span process startup,
per-station setup, and per-transaction paths; the per-call cost is one
statSync only when an override is set (no syscall on the no-override
fallback).
- Assert the full AssetsDirError message (name + message) in
tests/resolveAssetsDir.test.ts, following the repository's strict
error-message assertion convention.
* fix: derive station configuration and diagnostics paths from the resolved assets layout
ChargingStation: derive the per-station configuration file from the
template file's parent directory (the assets directory) instead of a
string replace of 'station-templates', which broke EVSE_ASSETS_DIR
overrides whose path contains 'station-templates'.
OCPP16 GetDiagnostics: resolve the log scan and the diagnostics
archive against the working directory (where winston writes the logs
and where the archive is created) instead of module-relative, so the
command works in dev-from-source; drop the now-unused fileURLToPath
import.
README: note in the <assets-dir> definition that EVSE_ASSETS_DIR may
point to a custom assets directory (absolute, or relative to the
working directory).
* refactor: address remaining review findings on assets resolution
- Rename OCPP_SCHEMA_DIR to OCPP_SCHEMA_SUBDIR (it is a relative path
fragment of the assets directory, not a directory path)
- Drop the unused AssetsDirError re-export (canonical barrel: src/exception)
- JSDoc: document the layout as a contract not validated by the resolver;
correct the ui-protocol/ claim (source-tree-only, artifact for humans)
- README: source-managed content rebuild wording; backtick <assets-dir>
in 3 table cells; idTagsFile/evProfilesFile are file names at the root
of the assets directory (basename-only resolution)
- Bootstrap JSDoc: unset-or-empty override; warn about stale fixture
content on manual full-suite runs; document the first-import contract
and the string-equality fixture check
- Tests: cover the relative-missing override branch (raw and resolved
paths in the error message); assert AssetsDirError messages by
substrings per the suite convention instead of duplicating the format
* [autofix.ci] apply automated fixes
* refactor: centralize asset path derivation in resolveAssetsDir helpers
- add getAssetFilePath, getConfigurationsDir, getStationTemplatesDir and getConfigFilePath so every <assets-dir>/<subpath> is derived from one join primitive
- Bootstrap, HelpersConfig, Configuration and OCPP20CertificateManager use the derived helpers instead of join(resolveAssetsDir(), ...)
- AssetsDirError: drop the redundant name override (BaseError sets name via new.target.name)
- README: fix the \`<assets-dir>\`/configurations wording to <assets-dir>/configurations
- tests: cover getOcppSchemaBaseDir with and without EVSE_ASSETS_DIR
- apply pnpm format (prettier + eslint) on the changed files
* refactor: derive the OCPP 1.6 schema path via getAssetFilePath
parseJsonSchemaFile now uses the single join primitive from resolveAssetsDir, dropping the last direct join(resolveAssetsDir(), ...) call site and the unused node:path import. No behavior change.
* fix(assets): preserve nested persistence and cache paths
* Refactor supervisionUrls to use SupervisionUrlSchema
Updated supervisionUrls to use SupervisionUrlSchema for validation.
* Add SupervisionUrlSchema to index export
* fix(ui-server): validate the supervision URL scheme at the UI request gate
Signed-off-by: VitorCoelho <vitorc195@gmail.com>
* Add test for rejecting invalid supervision URLs
* test(utils): assert the supervision URL schemes match the ws client
Signed-off-by: VitorCoelho <vitorc195@gmail.com>
* docs(readme): document the accepted supervisionUrls schemes
Signed-off-by: VitorCoelho <vitorc195@gmail.com>
* fix(simulator)!: bump the configuration schema version to 2
v2 narrows `supervisionUrls` to URLs whose scheme the ws client can
dial. The v1 to v2 migration is a pass-through: values outside that set
cannot be rewritten automatically and are reported by schema validation.
BREAKING CHANGE: a configuration file whose `supervisionUrls` uses a
scheme the ws client cannot dial now fails validation at startup instead
of failing on each connection attempt.
Signed-off-by: VitorCoelho <vitorc195@gmail.com>
* chore(config): stamp the configuration template with schema version 2
Signed-off-by: VitorCoelho <vitorc195@gmail.com>
* test(utils): cover the v1 to v2 configuration migration
Signed-off-by: VitorCoelho <vitorc195@gmail.com>
* test(utils): cover v1 configurations under schema version 2
Signed-off-by: VitorCoelho <vitorc195@gmail.com>
* docs(readme): document configuration schema version 2
Signed-off-by: VitorCoelho <vitorc195@gmail.com>
* fix(simulator): reject supervision URLs with a fragment or an empty ws+unix socket path
The ws client throws for a URL with a fragment identifier and for a
ws+unix URL without a pathname, so these values passed the gate and failed
per station at connection time. Reject them in SupervisionUrlSchema.
Signed-off-by: VitorCoelho <vitorc195@gmail.com>
* test(utils): cover fragments and ws+unix socket paths in the supervision URL schema
Add a fragment URL and ws+unix URLs without a socket path to the ws
parity test, together with a ws+unix URL with a request path and a
hostless network URL, which the ws client dials.
* fix(charging-station): guard every supervision URL parsing path
The invalid-supervision-URL fix only covered the stationInfo branch of
setSupervisionUrl and passed a cause option BaseError does not accept, so the
file did not typecheck. Route every URL construction through a single
parseSupervisionUrl helper that raises a BaseError naming the offending field,
covering the template/config path, the OCPP-configuration key branch and the
wsConnectionUrl getter, and add the regression tests the issue requires.
Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* test(charging-station): cover every supervision URL guard and isolate the suite
The getter guard added by the previous commit had no test: reverting it left
the suite green. The three supervision URL tests also leaked a temp template
directory each, because a station built with the default persistence writes its
configuration after the synchronous afterEach ran cleanup.
Move them into a dedicated ChargingStation-SupervisionUrl suite with an async
teardown that drains the pending write, disable persistence on every station
they build, add the missing getter regression, drop two inert creation options
and pin the exact BaseError message and name.
Name the charging station in the diagnostic and report the configured base URL
instead of the derived one, so the error matches the issue acceptance criteria
and no longer quotes a value the operator never configured.
Jérôme Benoit [Wed, 7 Oct 2026 15:50:13 +0000 (17:50 +0200)]
chore: replace volta with mise for toolchain management (#2143)
* chore: replace volta with mise for toolchain management
Volta is unmaintained and its maintainers recommend mise. Declare node and
pnpm in .mise.toml, drop the per-package volta blocks, point the sea script at
mise which node and update the README setup instructions.
CI is unchanged: it already provisions node and pnpm through
actions/setup-node and pnpm/action-setup, and its node matrix must keep testing
several versions.
Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* chore: read mise toolchain versions from package.json
Follow poolifier#3334 without duplicating Node.js or pnpm pins. Keep explicitly selected runtimes through onFail ignore, and require a mise version verified to install the current pnpm release assets. Correct the Corepack setup instructions and ignore personal mise overrides and lockfiles.
Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* docs: keep development setup instructions focused on the project
fix(ui-server): validate UI request payloads against a canonical schema (#2129)
* fix(ui-server): validate UI request payloads against a canonical schema
The three UI transports only guaranteed the frame shape, never the payload
fields: WebSocket checked the SRPC tuple, HTTP only parsed JSON, and MCP was
the sole path with a per-procedure schema. Enforcement is now a single gate in
AbstractUIService.requestHandler, so all three transports share one contract.
Closes a targeting escalation: a non-array `hashIds` failed the
`Array.isArray` guard in sendBroadcastChannelRequest and therefore degraded
into a broadcast to every station. Also removes the unchecked
`deleteConfiguration as boolean` cast in the worker broadcast channel and the
now-duplicated payload type check in handleAddChargingStations.
Schemas are loose objects: OCPP PDU fields are forwarded untouched and stay
validated by the OCPP layer against the OCPP JSON schemas. Coverage is
exhaustive by construction (Record<ProcedureName, ZodType>).
Verified: 3961 tests, 0 fail; typecheck, lint and build green. The five new
regression tests fail on the previous commit.
* chore(serena): refresh project configuration
Written by the Serena tooling on a newer version: refreshed the
language-server list (deno, gleam, nextflow, wolfram, julia_fatou), added
included_apis / excluded_apis / agent_interface keys, and updated the
special-requirements notes. No functional change to the simulator.
* fix(ui-server): require the connector target the worker already requires
Review follow-up on the payload gate. START_TRANSACTION and
STATUS_NOTIFICATION were still treating connectorId as an unvalidated loose
PDU field, while handleStartTransaction and handleStatusNotification reject a
missing one with a per-station failure. A non-numeric value passed their
== null guard and reached the station.
Also aligns setSupervisionUrl with the MCP tool contract, which has always
required a real URL, and makes stopTransaction require transactionId - the
field handleStopTransaction actually reads - instead of declaring a
connectorId it ignores.
Five regression tests added; suite is 3963 tests, 0 fail.
* fix(ui-server): honour the OCPP connector/EVSE 0 targets in the payload gate
Review round 1. Two identifier semantics, on specification evidence: OCPP 1.6
6.31 (MeterValues) and 6.47 (StatusNotification) define connectorId >= 0 with 0
designating the main power meter / charging station main controller, 6.45
(StartTransaction) requires connectorId > 0, and OCPP 2.0.1 MeterValuesRequest
reserves evseId 0 for the main power meter. A single positive() constraint was
rejecting targets the simulator materialises by default
(OCPPConnectorStatusOperations.ts:187, OCPPServiceUtils.ts:1420, connector "0"
in every template). Split into connectorIdField / evseIdField (non-negative) and
physicalConnectorIdField (positive) for the cable lock and transaction
connectors.
Also founded by the review:
- supervisionUser is now the same RFC 7617 field everywhere; accepting a colon
on setSupervisionUrl made openWSConnection silently drop the auth.
- stopTransaction transactionId is integer, per StopTransaction.json; the string
branch was unreachable on a 1.6-only procedure.
- Error amplification capped: an invalid array produced one issue per element,
a 60k-element hashIds yielding a 3.8 MB errorMessage. Now 626 chars.
- evseId, the connectorStatus/status pair and meterValue[].sampledValue are
declared, closing the gaps left by the worker-side guards.
- Module header corrected: UIMCPServer flattens the OCPP payload, so all three
transports observe identical behaviour - only the wording overstated it.
- isEmpty for the issue path, dead duplicate schema removed, unused exports
dropped, barrel inventory updated, shared test helper, 8 tests added (13->21).
README states the bounds it left implicit.
* fix(ui-server): group payload violations per field and truly share the schemas
Round 2 review. Three defects were introduced by the previous fix commit.
The 10-issue cap dropped whole fields: 12 invalid options plus a non-string
template produced a message that never mentioned template, so the client
discovered it only on the next round trip. formatIssues now groups by top-level
field and reports the first violation plus a count; array indices collapse into
the group key. A 60000-element hashIds now yields a 79-character message and
every distinct field stays visible.
The 'shared' supervision fields were declared as unexported consts while
MCPToolSchemas still duplicated z.url(), the RFC 7617 regex and z.string()
verbatim, under a header claiming a field cannot mean two things. The three
primitives are now exported and imported; no literal duplication remains.
stopTransaction declared an evseId the worker never reads
(ChargingStationWorkerBroadcastChannel.ts:791-825), rejecting payloads for a
field the server ignores. Removed.
Also: transactionId is required in both modules; errorCode is declared optional
because 2.0.1 does not carry it and the gate is version-blind; the false
'2.0.x sends evseId instead' comment is corrected; the OCPP 1.6 connectorId
contradiction is recorded in the JSDoc; README gains the missing meterValues
section.
Tests 21 -> 27, each pinning a defect that previously survived mutation.
* fix(ocpp): resolve the 1.6 status alias and unblock the published MCP contract
Round 3 review. Two P1 regressions introduced by the previous commit.
buildStatusNotificationRequest copied only commandParams.status, so a
2.0.x-shaped payload produced a 1.6 PDU with status: undefined and AJV
rejected it per station. It now resolves connectorStatus ?? status, matching
OCPP20ServiceUtils:1292 and OCPPConnectorStatusOperations.ts:44, and refuses
out-of-enum values explicitly: Occupied is the only 2.0.1 status with no 1.6
counterpart, and a bare comment would have let it reach AJV again. This
predates the PR - the worker guard at :783 always accepted connectorStatus -
but the gate made it contractual and a test enshrined it as correct.
The MCP tool schema required transactionId at the top level while the server
publishes ocpp16Payload as StopTransaction.json, whose required list places
transactionId inside the payload. The SDK validates before the handler, so a
client following the advertised contract was rejected with -32602. Back to
optional: z.object strips undeclared keys silently, so removing the property
would have been worse.
Also: violations are grouped by the full normalized path, so nested sub-fields
stay visible and counted issues are no longer attributed to another path;
meterValues requires at least one of connectorId/evseId, the union the
version-blind gate can express and the worker's own condition; the module
header now states the layer split truthfully.
Tests 27 -> 33, plus 4 on the 1.6 builder.
* fix(ui-server): preserve MCP passthrough and unblock the web supervision form
Round 4 review. The PR body claimed all three transports observe identical
behaviour; they did not. UIMCPServer registered the tool schema as a bare
shape, so the SDK rebuilt it through objectFromShape in strip mode and the
handler received the payload stripped of undeclared top-level fields. Fixing
it needs BOTH halves - build the tool schemas as looseObject AND register the
object rather than its shape; either alone still strips. Verified as an
invariant over all 36 tool schemas: whenever a parse succeeds, an undeclared
top-level field survives.
The RFC 7617 rule on supervisionUser, extended from the MCP transport to the
shared gate, broke the shipped web UI: the modern form pre-fills the field
from the station and sends it unconditionally, so a station whose template
holds a colon in supervisionUser could no longer change its supervision URL -
a payload main accepted and the client structurally cannot omit. The form now
omits credentials left at their base value; the classic skin, starting empty,
still sends an empty field and keeps the documented clear-the-value meaning.
Also: the module header made three claims the code contradicts (it excluded
PDU members the gate does validate, called every schema loose, and described
transactionId as optional 250 lines below its own requirement), evseId's zero
semantics were attributed to statusNotification instead of meterValues, and
meterValue[].sampledValue optionality was untested - removing the .optional()
left the suite green.
* fix(ui-web): correct the supervision credentials hint
The hint still said credentials are sent verbatim and that leaving a field
empty clears the stored value. Since the form now omits credentials left at
their base, an emptied field that was never edited no longer reaches the
server, and a username containing ':' is rejected (RFC 7617). The text has to
describe the behaviour the form actually has.
* fix(ui-server): drop three bounds no specification or worker requires
Every remaining rejection has to name the rule that mandates it. Three did
not.
lockConnector: the message does not exist in the OCPP 1.6 core document
(grep -c returns 0) and ships no JSON schema - it is an OCA addendum. Only
StartTransaction (§6.45) and UnlockConnector (§6.53) declare connectorId > 0,
so those two keep the bound and lockConnector returns to the non-negative
integer the project already accepted. The JSDoc now cites a section per
procedure instead of §6.45 for three.
connectorIds: the worker strips it from every command except the two
automatic transaction generator ones
(ChargingStationWorkerBroadcastChannel.cleanRequestPayload), so the gate was
rejecting on 27 procedures a payload the server already accepted and then
discarded. It is now declared only where it is read.
supervisionUser in a station option: TemplateSchema accepts any string and
ChargingStation.openWSConnection deliberately degrades a colon-bearing user
to a warning with the auth omitted. Bounding the option more strictly than
the template made one configuration value load from a file but be rejected
over the API. The strict rule stays on SET_SUPERVISION_URL, where the
operator types the value, and on the web form, which now omits unchanged
credentials.
Also corrects the OCPP 2.0.1 section for MeterValuesRequest (§1.32.1, not
§1.31.1, which is LogStatusNotificationRequest) and names
supervisionUser/supervisionPassword in the module scope header. Two tests
pin the new acceptances.
* docs(ui-server): correct OCPP and MCP validation semantics
* fix(web): reject colon-bearing supervision usernames before submission
* fix(ui-server): redact credentials from failure diagnostics
* docs: clarify UI validation rationale and trim redundant comments
fix(ocpp): preserve metering owners and replay certainty (#2119)
- settle retained eager Updated waiters before a pre-send break
- seed queued replay ambiguity from persisted delivery history
- keep live EVSE owner and baselines across same-template reload
- account interval energy from final projected samples, truncating
serialized interval rounding while retaining remainder carry
feat(ocpp20): autonomous clock-aligned MeterValues per AlignedDataCtrlr (#2096)
* feat(ocpp20): build meter values for transaction-less connectors
Parameterize createVersionedSampledValueDispatcher and the shared
buildMeterValue body with a resolved {connectorId, evseId, transactionId?}
identity so a connector can be identified directly without an active
transaction (#2011 Category 2F, J01.FR.14 clock-aligned reporting).
- New export buildClockAlignedConnectorMeterValue(chargingStation,
{connectorId, evseId}, interval, measurandsKey?, context?)
- Transaction-scoped side effects stay transaction-gated: coherent-session
lookup, energy-register bookkeeping (updateConnectorEnergyValues) and the
one-time publicKeySentInTransaction flag
- Idle readings are unsigned by design: no signingConfig is constructed
without a transaction id
- Energy register falls back to getEnergyActiveImportRegisterByConnectorId
for idle connectors; transactional callers are bit-identical
- Export TestableOCPP20IncomingRequestService interface from __testable__
Refs #2011
* feat(ocpp20): autonomous clock-aligned MeterValues per AlignedDataCtrlr
Implement #2011 Category 2F: out-of-transaction, wall-clock emission of
MeterValuesRequest with ReadingContext Sample.Clock per OCPP 2.0.1
J01.FR.14/J01.FR.19.
- OCPP20ServiceUtils.emitClockAlignedMeterValues: one aggregated
MeterValuesRequest per online EVSE covering ALL its connectors (idle ones
included) with real measurands from AlignedDataCtrlr.Measurands; skips an
in-transaction EVSE iff SendDuringIdle=true; silent when an aggregate
collapses (sampledValue 1..* cardinality); interval read raw so
Interval=0 disables transmission per spec §2.2; gated by
AlignedDataCtrlr.Enabled (default false)
- ChargingStation: startAlignedMeterValues/stopAlignedMeterValues/
restartAlignedMeterValues mirroring the heartbeat timer, armed in
startMessageSequence and stopped in internalStopMessageSequence
- OCPP20VariableManager: restart the aligned timer when
AlignedDataInterval changes via SetVariables (heartbeat/wsPing hook
precedent), stop on non-positive values
* test(ocpp20): strengthen clock-aligned MeterValues coverage per review
- Add multi-connector aggregation case: one EVSE with two connectors must
yield a single aggregated MeterValuesRequest with two MeterValue entries
- Point the SignReadings fixture at SampledDataCtrlr.SignReadings, the
variable the dispatcher actually reads, instead of the unused
AlignedDataCtrlr.SignReadings key
- Assert the specific Rejected status for out-of-range interval sets
- Add OCPP20OptionalVariableName.SendDuringIdle and use it in the registry
and reader instead of a string literal (terminology harmonization)
- The registered AlignedDataCtrlr.SendDuringIdle variable is station-scoped
(no EVSE instance), so with it set to true an ongoing transaction anywhere
on the charging station stops clock-aligned MeterValues for ALL EVSEs
(J01.FR.20), not only the transaction-carrying EVSEs
- Restart the clock-aligned MeterValues timer on station template hot-reload
alongside heartbeat and WebSocket ping so a template-driven Interval change
takes effect without a protocol SetVariables round-trip
- Update tests: FR.20 station-wide suppression, idle-only emission with
SendDuringIdle=true, strengthened evseId=0 exclusion assertion
Refs #2011
* fix(ocpp20): treat pending transactions as ongoing for SendDuringIdle + test hardening
- SendDuringIdle station gate now counts pending transactions (transactionId
assigned before the TransactionEvent Started response is accepted), matching
resolveActiveTransaction semantics, so clock-aligned MeterValues are never
emitted inside the pending window when SendDuringIdle=true
- Seed the EVSE 0 energy template in tests so the evseId=0 exclusion test
fails under an iterateEvses(false) regression instead of passing vacuously
- Assert absence of signedMeterValue on emitted idle samples, pinning the
unsigned-idle-readings wire format
Refs #2011
* fix(ocpp20): warn and skip clock-aligned timer without EVSE topology
Defensive guard: a station reaching startAlignedMeterValues without EVSE
topology (Connectors-only layout, rejected at initialize by
validateStationInfo but kept safe against future validation changes) would
make the aligned sweep a silent no-op — log a warning instead.
Refs #2011
* fix(ocpp20): align clock meter values signing and idle physics
- Route active connectors through the transaction-aware aligned builder so
Sample.Clock readings preserve coherent sessions and honor
AlignedDataCtrlr.SignReadings per J01.FR.21/J01.FR.22
- Keep transaction-less idle readings unsigned, omit SoC, emit zero imported
power/current, retain voltage, and read the flat connector energy register
- Accept AlignedDataCtrlr.Interval=0 through SetVariables and stop the timer;
distinguish normal disablement from invalid negative configuration
- Centralize the 900-second aligned interval default and scope the interval
side-effect hook by AlignedDataCtrlr
- Tighten OCPP 2.0 return typing, terminology, and behavioral coverage
Refs #2011
* [autofix.ci] apply automated fixes
* fix(ocpp20): harden aligned meter value snapshots
* fix(ocpp20): harden clock-aligned MeterValues timer log and public-key flag
Log "timer started" only when a new timer is actually armed, mirroring the
heartbeat pattern (previously the info log fired on the idempotent no-op
call). Guard the coherent-path publicKeySentInTransaction mutation with an
explicit transactionId check, symmetric with the random/fixed path and
robust to future coherent-session model changes.
Describe the AlignedDataCtrlr-driven autonomous MeterValues emission and its
documented deviations (free-running interval timer, station-scoped
SendDuringIdle suppression, no evseId=0 request, unsigned idle readings).
* refactor(ocpp): name meter-value identity type and align post-refactor references
Extract the duplicated resolved-identity object shape into a shared
ResolvedMeterValueIdentity type (used by buildIdentifiedMeterValue and the
versioned SampledValue dispatcher), reuse the snapshot const instead of
re-testing identity.snapshot, and correct JSDoc @link targets and the signing
warn context to buildIdentifiedMeterValue (the real shared builder). Document
the intentional snapshot re-projection pass and the one-connector-per-EVSE
aggregation assumption. No behavior change.
* test(ocpp): assert clock-aligned emission, faithful energy fixture, precise titles
Assert the aligned snapshot actually emits for the active coherent EVSE so the
read-only test cannot pass vacuously. Make the mock
getEnergyActiveImportRegisterByConnectorId honor meteringPerTransaction
(defaulting to transaction-scoped like production) instead of always reading the
transaction register, removing a latent fixture trap. Align the P=V*I invariant
test titles with their actual 0.01 W tolerance.
* fix(ocpp20): align autonomous MeterValues to wall-clock boundaries
Per OCPP 2.0.1 §2.7.12, clock-aligned intervals are evenly spaced per day from
00:00:00. Delay the first emission to the next wall-clock boundary (aligned to
the Unix epoch, i.e. 00:00:00 UTC) via setTimeout, then emit every interval so
boundaries stay aligned for intervals dividing the day, instead of a
free-running timer phased on station start. Track the pending initial timeout
separately and clear both handles on teardown; guard double-arm across both.
Add a test asserting boundary alignment and thread setTimeout/Date faking
(now=0) through the existing timer lifecycle tests.
* fix(ocpp20): correct clock-aligned meter value delivery
* [autofix.ci] apply automated fixes
* fix(ocpp20): skip pending transactions in clock-aligned emission
A pending transaction (transactionId assigned, Started not yet accepted) no
longer triggers a TransactionEvent(Updated, MeterValueClock), which would be
out of order relative to the not-yet-sent Started; the EVSE still counts as
in-transaction so its connectors emit nothing. Factor the ongoing-transaction
predicate into a hasOngoingTransaction helper (dedup 2 sites) and catch (not
just finally) throws in the aligned timer callback to avoid an unhandled
rejection, plus fix the signing warn log context after dispatcher extraction.
* test(ocpp): align mock station getters with production semantics
getNumberOfPhases/getVoltageOut now read the live this.stationInfo instead of
the frozen creation-time overrides, so post-creation mutations are reflected
(matching how currentOutType is read). Document why the energy-register getter
uses meteringPerTransaction !== false (mirrors the DEFAULT_STATION_INFO seeded
default of true).
* fix(ocpp20): skip clock-aligned sweep while the WebSocket is offline
Clock-aligned values are wall-clock anchored; running the autonomous sweep
while disconnected only queues stale snapshots and grows the offline
TransactionEvent queue unbounded over long outages. Return early from
emitClockAlignedMeterValues when the connection is not open (the timer keeps
ticking and resumes on reconnect). Transaction-driven meter values are
unchanged.
Re-arm aligned sampling after accepted reconnects, treat pending remote
starts as idle until Started is accepted, and keep autonomous snapshots
out of offline buffers. Allow fixed aligned measurands without requiring
an unrelated Energy.Active.Import.Register template.
* fix(ocpp20): close clock-aligned lifecycle races
The autonomous clock-aligned MeterValues emit awaited an eager
(queue-before-delivery) transaction event settlement that was never
resolved when the drain preserved the event for later replay
(offline/transport error). This left emitClockAlignedMeterValues
hanging forever, timing out the whole test suite.
Settle the eager delivery as deferred in the preserve-for-replay path
so the emit resolves once the event is safely buffered; the event
stays queued and delivers on reconnect.
Remove seven tests that were committed in a never-passing state (they
fail at the commit that introduced them) and assert behaviour the
implementation does not provide: they create an in-flight delivery
scenario while expecting the queued/offline predecessor path, block the
wrong request, and rely on flushPendingPromises timing. Removing them
unblocks the suite; the covered behaviour needs a redesigned,
deterministic test aligned with the shipped per-EVSE serialization.
- exclude Current.Offered/Power.Offered/State of Charge from the EVSE 0
station aggregate (J01.FR.14 note: setpoints are not measured values and
the grid meter has no such measurands)
- skip the unused rollback deep clone when enqueueing a non-oversized
lifecycle core, keeping the saturated queue append allocation-light
- report the coherent-sample snapshot energy register in transaction scope,
consistent with computeCoherentSample
- avoid a redundant aligned-timer restart when AlignedDataCtrlr.Enabled is
set to its current true value
- restore Constants member spacing to reduce unrelated diff churn
* fix(ocpp20): tear down clock-aligned timer on degraded stop
- Stop the autonomous clock-aligned MeterValues timer in finalizeTransport so a
shutdown short-circuited by a persistence error or timeout (before the stop
message sequence runs) cannot leave the timer re-arming and emitting after the
station has stopped; add a regression test for the timed-out stop path.
- test: add stopAlignedMeterValues no-op to the station mock factory and the
performStop stationLike fixtures now that finalizeTransport tears it down.
- test(ocpp20): correct the Heartbeat suite @file/@description and describe name
to cover the outgoing CALL serialization gate it actually exercises.
- test(ocpp20): drop the vacuous EVSE 0 aggregate state-of-charge exclusion
assertion (state of charge is non-additive and never enters the aggregate),
keeping the meaningful transaction-event assertion.
* refactor(ocpp20): align cross-component imports with barrel discipline
Audit-driven conformance fixes:
- Route cross-component imports through barrels: export
TransactionMeterValueDeliveryBarrier via charging-station/index.ts and the
public-key delivery helpers via ocpp/index.ts; repoint ocpp/1.6, ocpp/2.0 and
broadcast-channel consumers, and import ConnectorStatus from types/index.ts so
the stated import boundary actually holds.
- Use MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_INTERVAL instead of a hardcoded
measurand string literal in TransactionIntervalUtils.
- Drop a redundant `as unknown as` cast around public ChargingStation.isStopping().
- Add mandatory @file/@description header to the two new test files.
- docs(serena): refresh the per-station-state/WeakMap convention to describe the
transient process-local WeakMap pattern the code actually uses.
* style(tests,ocpp): apply remaining project conventions from audit
- tests: rename 357 `it()` cases to the documented `should [verb]` form and
switch 125 `assert.deepEqual` to `assert.deepStrictEqual`; add the mandatory
afterEach(standardCleanup) to the two new pure-util suites.
- src: use isEmpty()/isNotEmptyArray() for emptiness checks instead of raw
.length/.size comparisons; replace direct structuredClone() with the clone()
utility; add the missing logPrefix() to readAlignedDataIntervalSeconds logs.
* [autofix.ci] apply automated fixes
* test(ocpp20): pin register read time in coalesce test to remove wall-clock proration
The "coalesce TxEnded sampling behind an admitted triggered snapshot" test
asserted exact interval energy values (30/60 Wh) that are prorated by
real wall-clock elapsed since transactionEnergyActiveImportRegisterLastUpdatedAt.
On a slow host (windows-latest, Node 22.x) the elapsed real time between the
register read and the sample build added ~0.02 Wh, yielding 60.02 !== 60.
Pin the register read timestamp ahead of the sample window so
getTransactionObservationInterval() clamps elapsed to 0, making the interval
energy reflect only the deterministic register delta on any platform.
* fix(ocpp): address PR review findings on meterValues, energy lookup, reset
- ChargingStationWorkerBroadcastChannel.handleMeterValues: guard each
caller-supplied meterValue entry's sampledValue as an array before
scanning for public keys, throwing a typed BaseError instead of a raw
TypeError on malformed payloads (e.g. meterValue: [{}]).
- ChargingStation.getEnergyActiveImportRegisterByTransactionId: resolve the
connector status directly by the globally-unique transactionId instead of
round-tripping through a bare connectorId, which lost the EVSE qualifier
for EVSE-local connector ids and returned the wrong EVSE's register.
- HelpersConnectorStatus.resetConnectorStatus: drop the duplicated
delete of transactionEnding.
Regression tests added for the meterValues guard and the EVSE-local
energy resolution; the test mock is kept faithful to the fixed source.
- OCPP16IncomingRequestService.isRemoteStartTransactionChargingProfileValid:
restore the station log prefix lost when the validation was split out of
setRemoteStartTransactionChargingProfile, so charging-profile rejections at
remote start stay traceable per station in multi-station logs.
- CoherentSampleComputer: the zero-snapshot branch now sources
energyRegisterWh from transactionEnergyActiveImportRegisterValue, matching
the field's documented transaction-scoped semantics and the two other
build sites (the station-scoped register was inconsistent).
* refactor(meter-values): own the public barrel, drop charging-station re-exports
Promote meter-values/ to a first-class component: its index.ts barrel now
exports the full public surface (coherent generation, unit/interval helpers and
the transaction MeterValue delivery barrier), and charging-station/index.ts no
longer re-exports any of it. Consumers (ocpp, broadcast-channel, tests) import
meter-values symbols through meter-values/index.js, and broadcast-channel no
longer deep-imports its sub-modules. Documents meter-values as a separate
component in .serena project overview.
* refactor(meter-values): route remaining root imports through the barrel
Two charging-station root files still deep-imported meter-values sub-modules,
contradicting the barrel discipline documented for the component:
TransactionEventQueueUtils (canonicalizeCustomData) and HelpersConnectorStatus
(getRepresentedTransactionIntervalEnergyWh) now import from meter-values/index.js.
Also fix a copy-pasted log label: the MeterValues send-error path in
sendClockAlignedMeterValuesRequest referenced emitClockAlignedMeterValues.
chore(deps): update eslint monorepo to v10 (#1671)
* chore(deps): update eslint monorepo to v10
* build(deps): bump neostandard to 0.14.0-next.1 for ESLint 10 support
ESLint 10 removed the deprecated SourceCode#isSpaceBetweenTokens method,
which the transitively-pinned @stylistic/eslint-plugin@2.11.0 (via
neostandard@0.13.0) still called, crashing `eslint` at runtime
(TypeError: sourceCode.isSpaceBetweenTokens is not a function).
Apply resulting @stylistic 5 formatting auto-fixes and resolve new
findings from ESLint 10's updated eslint:recommended and eslint-plugin-n:
- no-useless-assignment: drop dead initializers / useless param mutation
- n/no-callback-literal: rename non-error-first callback param
- preserve-caught-error: attach cause to rethrown error
* [autofix.ci] apply automated fixes
* build(deps): widen neostandard range to ^0.14.0-next.1
fix(deps): update all non-major dependencies (#2117)
* fix(deps): update all non-major dependencies
* fix(deps): keep skott at ^0.35.11 and regenerate lockfile
skott@0.35.12 depends on skott-webapp@^2.4.0, but skott-webapp is only
published up to 2.3.0 on npm, so the tree cannot be resolved
(ERR_PNPM_NO_MATCHING_VERSION) — this is why Renovate could not update
pnpm-lock.yaml on this branch. Revert the skott bump to ^0.35.11 (which
depends on the published skott-webapp@^2.3.0) and regenerate the lockfile so
the eslint-plugin-jsdoc and zod updates land with a consistent, installable
lockfile.
Jérôme Benoit [Sun, 23 Aug 2026 15:33:04 +0000 (17:33 +0200)]
chore: remove Sandcastle integration (#2093)
* chore: remove Sandcastle integration
- delete .sandcastle agent harness, Docker sandbox and scheduled workflow
- drop @ai-hero/sandcastle devDependency and 'sandcastle' script
- remove .sandcastle from lint/format globs, tsconfig include and .cfignore
- drop unused cspell words and pnpm minimumReleaseAgeExclude entry
Jérôme Benoit [Fri, 14 Aug 2026 22:08:21 +0000 (00:08 +0200)]
refactor(webui): order station action buttons by CSO lifecycle (#2084)
* refactor(webui): order station action buttons by CSO lifecycle
Reorder the station-card action buttons in both skins to follow the
Charging Station Operator lifecycle (bring-up, provisioning, exploitation,
observability):
- modern StationCard: Start/Stop, Connect/Disconnect, Configuration,
Authorize, Details
- classic CSData: Start/Stop, Connection, Set Supervision Url,
Change Configuration, Show Details
This fixes Authorize (runtime) preceding Configuration (provisioning) and
Details being wedged between active commands. Action sets, labels, handlers,
routes, styling and the isolated Delete button are unchanged.
Make the classic CSData show-details toggle assertions look the button up by
id instead of a fixed index, so they stay robust to ordering.
Add id-based navigation tests for the change-configuration toggle in
classic CSData, mirroring the show-details coverage: assert on() pushes
the change-configuration route with hashId/chargingStationId params and
off() pushes back to charging-stations. Closes the coverage gap for the
toggle reordered in this change.
* test(webui): select set-supervision-url toggle by id for consistency
Align the set-supervision-url toggle navigation tests with the
change-configuration and show-details ones by looking the toggle up via
its id instead of a fixed index, so the whole toggle-navigation suite is
order-independent and uses a single selection convention.
Jérôme Benoit [Fri, 14 Aug 2026 18:03:20 +0000 (20:03 +0200)]
fix(webui): keep modern station-card Delete action aligned on the footer row (#2083)
The modern station-card footer laid the 5 action buttons in a non-wrapping
inline-flex group beside the Delete button, inside a wrapping footer. Once the
group's max-content width exceeded the card, the footer broke the line between
the group and Delete, dropping Delete onto its own row where margin-left:auto
pinned it bottom-right (orphaned) instead of aligned with the other actions.
Make the footer a single flex line (flex-wrap: nowrap) and let the action
group wrap its buttons internally (flex-wrap: wrap) so it reflows across rows
on narrow cards while Delete stays on the same row, pinned right. align-self:
flex-start top-aligns Delete with the first action row when the group wraps.
Presentational modern-skin CSS only; no markup or handler change.
Jérôme Benoit [Fri, 14 Aug 2026 13:39:35 +0000 (15:39 +0200)]
fix(simulator): seed autoRegister and ocppProtocol defaults into stationInfo (#2082)
* fix(simulator): seed autoRegister and ocppProtocol defaults into stationInfo
autoRegister and ocppProtocol had no default, so a template omitting them
left stationInfo carrying undefined for both. Raw consumers of stationInfo
— the UI data payload (buildChargingStationDataPayload) and the persisted
configuration — therefore received undefined, so the Web UI station details
showed an empty placeholder for Auto Register and OCPP Protocol.
Both are static defaults (no derivation), so seed them in DEFAULT_STATION_INFO
(autoRegister: false, ocppProtocol: OCPPProtocol.JSON) alongside currentOutType
and ocppVersion. getStationInfo applies them via
mergeDeepRight(DEFAULT_STATION_INFO, stationInfo), so an explicit template/file
value or option still wins (idempotent, no clobber) and a persisted config
predating the fields is backfilled on reload. Export OCPPProtocol from the
types barrel to keep Constants.ts importing types from a single source, as
OCPPVersion already does.
Runtime-neutral: ocppProtocol has no runtime read; the autoRegister === true
guards are unaffected by false vs undefined; the getHeartbeatInterval warn
guarded by === false is unreachable in the initialized pipeline because
initializeOcppConfiguration always seeds the HeartbeatInterval key first.
* refactor(test): extract persisted-config resolution into realStation helpers
The persisted configuration file/dir resolution from a real-station template
was inlined and duplicated across three suites (AutoRegisterOcppProtocol,
NumberOfPhases, ResetIdentity). Extract persistedConfigurationDir and
resolvePersistedConfigurationFile into StationHelpers.realStation.ts (single
source of truth for the temp-dir layout) and migrate all three call sites.
resolvePersistedConfigurationFile throws a descriptive error when no config
exists yet, replacing the silent `?? ''` fallback (which degraded into EISDIR).
Group the helper by concern (temp-dir lifecycle / construction / persisted-config
resolution) with section headers, and document the two new helpers in
TEST_STYLE_GUIDE.md.
* docs(test): harmonize helper-table param placeholders in TEST_STYLE_GUIDE
Jérôme Benoit [Thu, 13 Aug 2026 20:13:58 +0000 (22:13 +0200)]
test(simulator): drop manual test counters from worker broadcast-channel test (#2081)
The group-header comments carried hand-maintained (N tests) counters that rot
as tests are added or removed; the test runner already reports the count.
Remove them, keeping the descriptive group headers.
Jérôme Benoit [Thu, 13 Aug 2026 19:37:03 +0000 (21:37 +0200)]
feat(webui): allow editing charging station configuration (#2077)
* feat(webui): allow editing charging station configuration
Add a generic, data-driven editor for a charging station's current OCPP
configurationKey values in the Web UI (both classic and modern skins),
closing #1828.
A new CHANGE_CONFIGURATION UI protocol verb clones the SET_SUPERVISION_URL
chain end-to-end (UIClient -> ProcedureName/BroadcastChannelProcedureName ->
AbstractUIService mapping -> worker handler). To stay OCPP spec-faithful, the
change is applied through a new version-agnostic
ChargingStation.changeConfiguration seam that reuses the existing OCPP 1.6
ChangeConfiguration and OCPP 2.0.1 SetVariables handler logic (readonly
rejection, integer/bounds validation, heartbeat/WS-ping restarts, reboot
signalling) without emitting a CSMS response, then emits
ChargingStationEvents.updated so the UI refreshes.
OCPP 2.0.1 configurationKey entries are now seeded with their registry
mutability/rebootRequired flags, so readonly keys are correctly disabled in
the UI and reboot keys are flagged.
Address PR review coverage gaps:
- assert ChargingStation.changeConfiguration emits ChargingStationEvents.updated
only on Accepted/RebootRequired (drives the read-view refresh)
- assert the OCPP 1.6 seam applies changes without emitting a CSMS response
- add OCPP 2.0.1 seam read-only rejection and reboot-required cases
* refactor(webui): address review findings for change-configuration
- OCPP 2.0.1 seam: set the resolved `instance` only on the SetVariables
`component` (drop the redundant `variable.instance`); the registry models
the instance as component-scoped and internal resolution reads
`variable.instance ?? component.instance`, so this is behavior-neutral.
Add an instance-scoped non-regression test (TariffCostCtrlr.Enabled.Cost).
- useStationDetails: docstring no longer claims "read-only" now that it
exposes editableConfigurationKeys.
- ui/web README: "edit configuration" -> "change configuration" to match the
UI/code terminology.
- ModernLayout: order the ChangeConfigurationDialog async declaration
alphabetically and put :hash-id before :charging-station-id, matching the
sibling dialogs.
* refactor(webui): hoist change-configuration draft state into the shared composable
Address the second-round review findings:
- M1 (DRY): move the per-key draft values, seeding watch and save() from both
skin components into useChangeConfigurationForm (now takes the editable keys
ref and exposes { draftValues, pending, save }), mirroring useSetUrlForm which
owns its form state. The classic action and modern dialog become pure UI.
- M2: add direct unit tests for the worker CHANGE_CONFIGURATION handler
(delegation, empty-value accepted, missing/empty/non-string key or value
rejected with BaseError).
- M3: align residual "edit"/"editing" wording with the "change configuration"
terminology in docstrings (kept editableConfigurationKeys as a property name).
* refactor(webui): validate required broadcast-channel string fields with isNotEmptyString
Replace the raw `typeof x !== 'string' || isEmpty(x)` guards in the
CHANGE_CONFIGURATION and SET_SUPERVISION_URL worker handlers with the
`!isNotEmptyString(x)` type guard (the repo-wide idiom, ~78 usages), which also
narrows the field to a non-empty string. Both handlers are switched together so
the file keeps a single convention. `isEmpty` remains used for the
empty-response status checks; the `value` field stays `typeof value !== 'string'`
since an empty value is a legitimate configuration value.
* refactor(webui): tidy change-configuration naming, reverse-map and MCP schema
Third-round review nits:
- M1: rename `editableConfigurationKeys` -> `visibleConfigurationKeys` (the
collection includes read-only keys; the name now matches its source util
getVisibleConfigurationKeys). Propagated across useStationDetails,
useChangeConfigurationForm, both skin components and the tests.
- M2: build OCPP20VariableManager's flat-key reverse map as a declarative
private instance field (like #validComponentNames) instead of a mutable
module-level for-loop; the dedup guard was inert (composite key names are
unique).
- T1: inline the single-caller private `submit` into `save`.
- T2: enforce a non-empty `key` at the MCP option layer (z.string().min(1));
`value` stays unconstrained (empty is a valid configuration value).
Add component tests for the classic `ChangeConfiguration.vue` action and the
modern `ChangeConfigurationDialog.vue`, which were shipped without component
tests and dropped ui/web coverage below the CI thresholds (the failing
`Build dashboard / Node 24.x / ubuntu-latest` cell runs `pnpm test:coverage`).
Tests exercise the observable contracts already verified in-browser: not-found
panel, empty-state, non-visible key exclusion, read-only input/button disabling,
editable save invoking `changeConfiguration`, reboot-required notice, read-only
save guard and backend-rejection error toast. Adds the missing
`changeConfiguration` method to the shared `MockUIClient`.
Refs: #1828
* fix(ocpp): reject empty value for integer 1.6 configuration keys
Address the initial-review findings on PR #2077:
- T1 (fix): the OCPP 1.6 ChangeConfiguration handler accepted an empty/blank
value for integer keys (Number('') === 0 passed the non-negative-integer
check) and persisted the raw empty string. Reject it explicitly via
isNotEmptyString, matching the spec (values not conforming to the expected
integer format are Rejected). Covers both the UI seam and the OCPP wire path.
Add a discriminating test (empty value -> REJECTED, value preserved).
- T2 (fix): correct the misleading worker error message
"'value' field is required" -> "'value' field must be a string" (an empty
string is a legitimate value; only a non-string is rejected).
- T3 (test): rename the composable test description "editable keys" ->
"visible keys" to match the renamed visibleConfigurationKeys parameter.
- M1 (docs): document, on OCPP20 changeConfiguration, that the resolved
instance is carried on component.instance and is internal-only (never
emitted to a CSMS), so the component- vs variable-instance distinction is
immaterial here.
- M2 (docs): add the missing 'changeConfiguration' ProcedureName block to the
README UI protocol reference.
Kept as-is with rationale: the version-agnostic seam reuses the 1.6-named
ChangeConfigurationResponse type (established CommandResponse union convention,
type-safe alias); the modern dialog table style follows the skin's
per-component scoped convention. Cleartext config values in the edit UI are
pre-existing (already exposed via LIST_CHARGING_STATIONS / MCP) and out of
scope.
Refs: #1828
* docs: clarify changeConfiguration value validation in the README protocol reference
Address review finding TR1: the changeConfiguration block claimed "an empty
string is allowed", which is only true at the message-envelope level. A value
invalid for the target key (e.g. a non-integer or empty value for a numeric
1.6 key) is rejected. Reword the value clause to state that invalid values are
rejected and keep it version-agnostic (the verb serves both OCPP 1.6 and
2.0.1). No code change.
Refs: #1828
* test(webui): factor duplicated configuration-keys station fixture into a shared factory
The `stationWithKeys` helper was duplicated verbatim in the classic and modern
skin test files. Mirroring the test-factorization convention consolidated in
main (PR #2078: shared test helpers, no per-file scaffolding duplication),
extract it into the canonical fixture home `tests/unit/constants.ts` as
`createStationWithConfigurationKeys`, and migrate both skin test suites to it.
Removes the now-unused local helpers and `ConfigurationKey` imports. No behavior
change (ui/web 592/592).
Refs: #1828
* test(webui): migrate remaining inline config-key fixtures to the shared factory
Address review finding TR1: complete the DRY migration started with
createStationWithConfigurationKeys. Replace the 13 remaining inline
`createChargingStationData({ ocppConfiguration: { configurationKey: … } })`
sole-override fixtures across the stationDetails, useStationDetails, ShowDetails
and ShowDetailsDialog test blocks with the shared factory. The empty-
ocppConfiguration case (stationDetails.test.ts, tests the absent-key fallback)
and all sites carrying additional overrides are intentionally left inline. No
behavior change (ui/web 592/592).
- OCPP16 integer-key guard: consolidate the two lines commenting the same guard
into one coherent, accurate rationale — why Number() over convertToInt
(truncates '1.5' → 1, throws on ''/'abc') and why the explicit isNotEmptyString
check. Number() yields a non-integer float ('1.5' → 1.5) or NaN ('abc'), both
caught by !Number.isInteger; isNotEmptyString rejects '' (Number('') === 0 would
otherwise pass).
- OCPP20 changeConfiguration JSDoc: remove redundancy with the delegate's JSDoc
and compress the instance-placement caveat, keeping every non-derivable fact.
Comment-only change; no behavior change.
Refs: #1828
* refactor(webui): single-source config-key formatting and align a11y state
- Export the shared formatBoolean helper and reuse it for the Readonly/Reboot
cells in both change-configuration skins (drop the 4 inline Yes/No literals).
- Classic Save button: expose aria-busy while a change is in flight, matching
the modern ActionButton (no visual spinner: classic has no such token).
- Drop the redundant aria-labelledby (and its useId) on the modern table whose
<caption> already names it, and the partial aria-disabled on the inputs whose
native disabled is authoritative.
- Add tests: Readonly/Reboot cell rendering and in-flight aria-busy in both
skins, plus modern parity for reboot-notice, read-only-not-submitted and
error-toast (mirroring the classic suite).
Refs: #1828
* docs: clarify metadata-driven reboot notice and safe 2.0.1 status collapse
- useChangeConfigurationForm: note that the reboot notice derives from the key
metadata, not the runtime status, because the worker response collapses
ACCEPTED|REBOOT_REQUIRED into a boolean success.
- OCPP20 status mapping: note that UnknownComponent/UnknownVariable are
unreachable via changeConfiguration (resolveConfigurationKeyName gates unknown
keys) and that Record exhaustiveness is compile-enforced.
- Yes/No cell test: use column-asymmetric keys (readonly-only vs reboot-only)
so an accidental swap of the Readonly/Reboot columns fails the assertion.
- Add a guard that the OCPP parameters table is named via its <caption> in
both skins (the modern table dropped its redundant aria-labelledby).
Refs: #1828
* refactor(ocpp): use isEmpty for the SetVariables result check
Replace `response.setVariableResult.length === 0` with `isEmpty(...)` in the
2.0.1 changeConfiguration seam, matching the repo convention (isEmpty is
already imported and used for arrays in this file).
Refs: #1828
* test(webui): fit CHANGE_CONFIGURATION worker tests into the group structure
The two CHANGE_CONFIGURATION describes (status collapse + handler) were wedged
between Group 1 and Group 2 without a group banner. Relocate them after Group 2
under a numbered "Group 3" banner (restoring ascending group order and filling
the pre-existing gap), and correct the stale Group 4 count (8 -> 9 tests).
Jérôme Benoit [Tue, 11 Aug 2026 22:09:54 +0000 (00:09 +0200)]
fix(simulator): seed numberOfPhases default into stationInfo (#2078)
* fix(simulator): seed numberOfPhases default into stationInfo
The derived numberOfPhases default (AC: 3, DC: 0) lived only in the
getNumberOfPhases getter and was never written to stationInfo. Raw
consumers of stationInfo — the UI data payload (buildChargingStationDataPayload)
and the persisted configuration file — therefore received an undefined
numberOfPhases, so the Web UI showed an empty placeholder instead of the
effective phase count.
Seed stationInfo.numberOfPhases via the existing getNumberOfPhases getter
in getStationInfo, post-merge and source-agnostic, so both freshly
template-derived and already-persisted (legacy) configurations are fixed.
Idempotent: an explicit AC template value is preserved (?? 3), DC pins 0.
Backend consumers keep reading the getter and are invariant.
* test(simulator): align numberOfPhases test names with should-prefix convention
* test(simulator): cover DC phase pinning and persisted-config backfill
Add two discriminant cases to the numberOfPhases seeding suite:
- DC pins numberOfPhases to 0 even when the template sets a value
- a persisted configuration predating the field is backfilled on reload
(the file-sourced path that motivated seeding in the orchestrator)
- reuse the canonical flushMicrotasks test helper instead of a local
node:timers/promises import
- expand the test @file description to cover DC pinning and legacy backfill
- align the seed comment terminology on "backfill"
* test(simulator): dedupe test baseName literal into a constant
* docs(simulator): fix backfill comment accuracy and align seed wording
* test(simulator): inline baseName to match sibling station-test convention
* test(simulator): consolidate real-station-from-template scaffolding into a shared helper
Six charging-station tests each hand-rolled the same temp-dir template
scaffolding to build a real ChargingStation (the mock factory bypasses
initialize()/getStationInfo()). Extract writeStationTemplate/copyStationTemplate/
createStationFromTemplate/cleanupStationTemplates into StationHelpers.realStation,
migrate all six tests, and document the helper in TEST_STYLE_GUIDE.
* test(simulator): share temp-file and singleton-reset test helpers
Extract the mkdtemp/write/cleanup boilerplate into tests/helpers/TempFiles.ts
(createTempDir/writeTempFile/cleanupTempDirs) and a resetSingleton() helper into
TestLifecycleHelpers, then migrate the file-I/O tests (IdTagsCache, EvProfiles,
JsonFileStorage, Configuration-HotReload, FileUtils, UIMCPServer integration) and
the singleton-reset tests (Bootstrap, SharedLRUCache, IdTagsCache) onto them.
* docs(test): document TempFiles helpers and resetSingleton in the style guide
Jérôme Benoit [Mon, 10 Aug 2026 17:38:42 +0000 (19:38 +0200)]
fix(webui): polish the station details view (panel placement + section/key-value separation) (#2076)
CSS/Vue-only polish of the #993 Show details view: classic panel-placement fix + section/key-value separation; modern section panels, promoted titles and tighter symmetric row separators; not-found escape; toggle-navigation test coverage.
Jérôme Benoit [Mon, 10 Aug 2026 15:51:27 +0000 (17:51 +0200)]
feat(webui): add show details action for charging stations (#2072)
* feat(webui): add show details action for charging stations
Add a read-only "Show details" action in both Web UI skins displaying a
charging station's stationInfo, OCPP configuration parameters and other
relevant ChargingStationData fields. Frontend only: the data is already
shipped to the client via the charging station data payload.
- Shared pure util `stationDetails.ts` (single source of truth for section
selection, field formatting and OCPP key visibility filtering), matching
the existing `stationStatus.ts` pure-util convention.
- Classic skin: `show-details` router action + `ShowDetails.vue` panel,
triggered by a shared ToggleButton in the station actions cell.
- Modern skin: `StationDetailsDialog.vue` modal, triggered by a footer
"Details" button on the station card, wired through ModernLayout.
- Supervision password is always masked; supervision URL is host-only.
Closes #993
* [autofix.ci] apply automated fixes
* refactor(webui): centralize OCPP row formatting and address review
Review-round-1 fixes for the "Show details" action:
- Move OCPP cell formatting (readonly/reboot/value) into the shared
`buildConfigurationRows` util so both skins render identical, single-
sourced rows instead of duplicating the ternaries.
- Format Boot Notification "Current Time" via toLocaleString (consistent
with "Last Update") and drop the unsafe double cast.
- Give the modern OCPP table an accessible name (aria-labelledby).
- Tests: assert password masking in the classic rendered panel, cover the
OCPP readonly/reboot/value cell formatting in both skins and the util.
* test(webui): tighten details tests and unify empty-value formatting
Review-round-2 fixes for the "Show details" action:
- Format the OCPP parameter value via the shared `formatValue` so an
empty-string value renders as the empty placeholder, consistent with
every other field (was `value ?? Ø`, which left '' blank).
- Strengthen the modern password-masking test to also assert the masked
placeholder, add coverage for the empty-string value case, and assert
the modern OCPP table's aria-labelledby accessible name.
* refactor(webui): address initial-review findings for show details
- M1 (DRY): extract shared `useStationDetails(hashId)` composable consumed by
both skins, removing the duplicated station/sections/configurationRows
computed (mirrors the shared `useSetUrlForm` precedent).
- M2 (terminology): unify the feature label on "Show Details" across the
classic header, the modern dialog title and the README; the modern card
keeps its terse "Details" button per the card convention; route id
unchanged.
- M3: drop the duplicated Boot Notification "Status" entry (kept as
"Registration Status" under General).
- N1: factor a private `formatDate` helper and guard both dates.
- N3: give each modern detail `<dl>` section an accessible name via useId().
- N2 (OCPP terminology) intentionally left as "OCPP Parameters" to match the
issue wording; documented in review.
- Tests: new `useStationDetails` composable tests; assert the modern section
aria-labelledby wiring; update the "Show Details" heading/title assertions.
* style(webui): harmonize modern show-details with skin conventions
- H3: extract the shared `.modern-section-label` primitive (renamed from
`.modern-card__section-label`, single consumer migrated) and use it for the
modern dialog section headings instead of a one-off `.station-details__title`.
- H1: restyle the detail key/value list to the modern spec typography
(uppercase muted `dt`, strong `dd`) in a left-aligned two-column grid fit
for the wider dialog (drops the ad-hoc space-between/right-align/hairlines).
- H2: restyle the OCPP table to the modern low-chrome table aesthetic
(border-collapse, no per-cell borders, muted uppercase headers, subtle row
separators) matching the existing `.modern-connector__tx-table` precedent;
scope word-break to values/keys so column headers no longer break mid-word.
Classic skin unchanged (already reuses the shared data-table system). No test
changes: DOM hooks (.station-details__list/__table), aria-labelledby and the
OCPP row formatting are preserved. Card rendering is visually unchanged.
* style(webui): resolve exhaustive-review nits for show details
- MIN-1: left-align the classic detail table cells (target th/td so the
shared `.data-table` center rule no longer wins), fixing centered values.
- MIN-2: modern dialog title to sentence-case "Show details — {id}" to match
the sibling dialog titles (classic header keeps Title Case per its convention).
- NIT-1: derive the OCPP heading id from useId() instead of a hardcoded id,
consistent with the section headings.
- NIT-2: extract a private `formatBoolean` helper reused by
buildConfigurationRows and formatValue's boolean branch.
- NIT-3: align the shared "Supervision Url" label with the existing majority
spelling used across the classic surface.
- NIT-4: rename StationDetailsDialog.vue -> ShowDetailsDialog.vue (matches the
classic ShowDetails.vue and the feature name); update wiring + tests.
"OCPP Parameters" kept (issue #993 wording). Tests updated for the new title,
useId-based aria and the rename. 567 tests green.
* style(webui): order ShowDetailsDialog async import alphabetically
* docs(webui): fix show details JSDoc wording and uniformize empty-state punctuation
The HTTP UI transport (UIHttpServer / ApplicationProtocol.HTTP) is
deprecated in favor of the MCP transport and will be removed. Drop its
Insomnia requests collection and the README pointer to it; the WebSocket
collection and the deprecation notice are kept.