From e0a50c58f62cf7fb0e7d9b41366215e175af616f Mon Sep 17 00:00:00 2001 From: =?utf8?q?J=C3=A9r=C3=B4me=20Benoit?= Date: Mon, 6 Jul 2026 13:52:59 +0200 Subject: [PATCH] =?utf8?q?refactor:=20convention=20alignment=20round=202?= =?utf8?q?=20=E2=80=94=20canonical=20defaults,=20helpers,=20dead=20exports?= =?utf8?q?,=20tests=20(#1956)?= MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit * 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` — 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> --- cspell.config.yaml | 1 + src/charging-station/ChargingStation.ts | 4 +- .../CoherentMeterValuesManager.ts | 2 +- src/charging-station/HelpersConfig.ts | 6 +-- src/charging-station/TemplateValidation.ts | 5 +- .../UIServiceWorkerBroadcastChannel.ts | 5 +- src/charging-station/meter-values/index.ts | 9 +--- .../ocpp/1.6/OCPP16RequestService.ts | 5 +- .../ocpp/1.6/OCPP16ServiceUtils.ts | 10 +++- .../ocpp/2.0/OCPP20CertSigningRetryManager.ts | 3 +- .../ocpp/2.0/OCPP20Constants.ts | 8 +++ .../ocpp/2.0/OCPP20RequestService.ts | 5 +- .../ocpp/2.0/OCPP20ServiceUtils.ts | 7 +-- .../ocpp/2.0/__testable__/index.ts | 10 +--- .../ocpp/OCPPRequestService.ts | 12 +++++ src/charging-station/ocpp/OCPPServiceUtils.ts | 6 +-- .../ocpp/OCPPSignedMeterDataGenerator.ts | 4 +- .../ocpp/auth/cache/InMemoryAuthCache.ts | 19 +++---- src/charging-station/ocpp/auth/index.ts | 4 -- .../auth/strategies/RemoteAuthStrategy.ts | 4 +- .../ocpp/auth/utils/ConfigValidator.ts | 20 +++++--- .../ui-server/AbstractUIServer.ts | 7 ++- .../ui-server/UIServerAccessPolicy.ts | 2 +- .../ui-server/UIServerFactory.ts | 2 +- .../ui-server/UIWebSocketServer.ts | 18 ++++--- src/charging-station/ui-server/mcp/index.ts | 1 - src/performance/PerformanceStatistics.ts | 4 +- src/utils/Constants.ts | 36 ++++++++++++- src/utils/StatisticUtils.ts | 2 +- src/utils/Utils.ts | 6 +-- src/utils/index.ts | 4 -- src/worker/WorkerUtils.ts | 10 +++- .../ChargingStation-Configuration.test.ts | 25 +++++---- ...PP16ServiceUtils-SignedMeterValues.test.ts | 5 +- .../2.0/OCPP20CertSigningRetryManager.test.ts | 13 ++--- .../ui-server/UIServerSecurity.test.ts | 5 +- .../performance/storage/StorageTestHelpers.ts | 4 +- tests/utils/ConfigurationMigrations.test.ts | 7 +-- tests/utils/MessageChannelUtils.test.ts | 15 +++--- tests/utils/TestNetworkConstants.ts | 10 ++++ tests/utils/Utils.test.ts | 2 +- tests/utils/helpers/ConfigurationFixtures.ts | 7 +-- ui/cli/src/client/lifecycle.ts | 9 +++- ui/cli/src/output/format.ts | 41 ++++++++++----- ui/cli/src/output/renderers.ts | 17 ++++--- ui/web/src/core/Constants.ts | 1 + ui/web/src/core/index.ts | 1 + .../src/shared/components/SkinLoadError.vue | 18 +++---- .../shared/composables/useAddStationsForm.ts | 23 ++++----- ui/web/src/shared/composables/useSkin.ts | 11 ++-- .../src/shared/composables/useStartTxForm.ts | 3 +- ui/web/src/shared/utils/index.ts | 1 + ui/web/src/shared/utils/nonEmptyString.ts | 8 +++ ui/web/src/shared/utils/stationStatus.ts | 51 ++++++++++--------- ui/web/src/skins/classic/ClassicLayout.vue | 2 +- .../components/charging-stations/CSData.vue | 3 +- .../skins/modern/components/ConnectorRow.vue | 2 +- .../skins/modern/components/SimulatorBar.vue | 2 +- .../skins/modern/components/StationCard.vue | 4 +- .../dialogs/SetSupervisionUrlDialog.vue | 2 +- ui/web/src/skins/modern/utils/errors.ts | 8 +-- 61 files changed, 328 insertions(+), 213 deletions(-) create mode 100644 tests/utils/TestNetworkConstants.ts create mode 100644 ui/web/src/shared/utils/nonEmptyString.ts diff --git a/cspell.config.yaml b/cspell.config.yaml index d90fa86b..02662796 100644 --- a/cspell.config.yaml +++ b/cspell.config.yaml @@ -37,6 +37,7 @@ words: - entrancy - recurrency - shutdowning + - subclassing - VCAP - workerd - yxxx diff --git a/src/charging-station/ChargingStation.ts b/src/charging-station/ChargingStation.ts index fe4fdcfe..f32f9d0c 100644 --- a/src/charging-station/ChargingStation.ts +++ b/src/charging-station/ChargingStation.ts @@ -1607,7 +1607,7 @@ export class ChargingStation extends EventEmitter { return this.stationInfo?.reconnectExponentialDelay === true ? computeExponentialBackOffDelay({ baseDelayMs: Constants.DEFAULT_EXPONENTIAL_BACKOFF_BASE_DELAY_MS, - jitterPercent: 0.2, + jitterPercent: Constants.DEFAULT_RECONNECT_JITTER_PERCENT, retryNumber: this.wsConnectionRetryCount, }) : secondsToMilliseconds(Constants.DEFAULT_WS_RECONNECT_DELAY_SECONDS) @@ -2787,7 +2787,7 @@ export class ChargingStation extends EventEmitter { sleep( computeExponentialBackOffDelay({ baseDelayMs: Constants.DEFAULT_EXPONENTIAL_BACKOFF_BASE_DELAY_MS, - jitterPercent: 0.2, + jitterPercent: Constants.DEFAULT_RECONNECT_JITTER_PERCENT, retryNumber: messageIdx ?? 0, }) ) diff --git a/src/charging-station/CoherentMeterValuesManager.ts b/src/charging-station/CoherentMeterValuesManager.ts index 5cdcd8ec..050e0e25 100644 --- a/src/charging-station/CoherentMeterValuesManager.ts +++ b/src/charging-station/CoherentMeterValuesManager.ts @@ -15,10 +15,10 @@ import type { ChargingStation } from './ChargingStation.js' import { BaseError } from '../exception/index.js' import { isEmpty, logger } from '../utils/index.js' import { getEvProfilesFile } from './Helpers.js' -import { disposeCoherentSessionRuntime } from './meter-values/CoherentSampleComputer.js' import { type CoherentSession, createCoherentSession, + disposeCoherentSessionRuntime, type EvProfilesFile, loadEvProfilesFile, resolveRootSeed, diff --git a/src/charging-station/HelpersConfig.ts b/src/charging-station/HelpersConfig.ts index 12a4d13b..cc43f9cc 100644 --- a/src/charging-station/HelpersConfig.ts +++ b/src/charging-station/HelpersConfig.ts @@ -307,13 +307,13 @@ export const getAmperageLimitationUnitDivider = (stationInfo: ChargingStationInf let unitDivider = 1 switch (stationInfo.amperageLimitationUnit) { case AmpereUnits.CENTI_AMPERE: - unitDivider = 100 + unitDivider = Constants.UNIT_DIVIDER_CENTI break case AmpereUnits.DECI_AMPERE: - unitDivider = 10 + unitDivider = Constants.UNIT_DIVIDER_DECI break case AmpereUnits.MILLI_AMPERE: - unitDivider = 1000 + unitDivider = Constants.UNIT_DIVIDER_KILO break } return unitDivider diff --git a/src/charging-station/TemplateValidation.ts b/src/charging-station/TemplateValidation.ts index 15ac7dbb..72d97073 100644 --- a/src/charging-station/TemplateValidation.ts +++ b/src/charging-station/TemplateValidation.ts @@ -101,10 +101,7 @@ function transformTemplate ( validated: Record, filePath: string ): ChargingStationTemplate { - if ( - validated.idTagsFile == null || - (typeof validated.idTagsFile === 'string' && !isNotEmptyString(validated.idTagsFile)) - ) { + if (!isNotEmptyString(validated.idTagsFile)) { logger.warn( `${moduleName}.transformTemplate: Missing id tags file in template file ${filePath}. That can lead to issues with the Automatic Transaction Generator` ) diff --git a/src/charging-station/broadcast-channel/UIServiceWorkerBroadcastChannel.ts b/src/charging-station/broadcast-channel/UIServiceWorkerBroadcastChannel.ts index 36a13f67..5460434c 100644 --- a/src/charging-station/broadcast-channel/UIServiceWorkerBroadcastChannel.ts +++ b/src/charging-station/broadcast-channel/UIServiceWorkerBroadcastChannel.ts @@ -1,4 +1,7 @@ -import type { AbstractUIService } from '../ui-server/ui-services/AbstractUIService.js' +// Type-only edge, erased at compile time. A value import through this barrel +// would pull `UIHttpServer` / `UIMCPServer` / `UIServiceFactory` transitively +// and close a runtime cycle. +import type { AbstractUIService } from '../ui-server/index.js' import { type BroadcastChannelResponse, diff --git a/src/charging-station/meter-values/index.ts b/src/charging-station/meter-values/index.ts index 4e8e91d8..3f4623b7 100644 --- a/src/charging-station/meter-values/index.ts +++ b/src/charging-station/meter-values/index.ts @@ -26,12 +26,7 @@ export { buildCoherentMeterValue } from './CoherentMeterValueBuilder.js' export type { BuildVersionedSampledValue } from './CoherentMeterValueBuilder.js' +export { disposeCoherentSessionRuntime } from './CoherentSampleComputer.js' export { createCoherentSession, isCoherentModeActive, resolveRootSeed } from './CoherentSession.js' export { loadEvProfilesFile } from './EvProfiles.js' -export type { - ChargingCurvePoint, - CoherentSession, - EvProfile, - EvProfilesFile, - ICoherentContext, -} from './types.js' +export type { CoherentSession, EvProfile, EvProfilesFile, ICoherentContext } from './types.js' diff --git a/src/charging-station/ocpp/1.6/OCPP16RequestService.ts b/src/charging-station/ocpp/1.6/OCPP16RequestService.ts index 43e34113..6b39f17c 100644 --- a/src/charging-station/ocpp/1.6/OCPP16RequestService.ts +++ b/src/charging-station/ocpp/1.6/OCPP16RequestService.ts @@ -129,10 +129,7 @@ export class OCPP16RequestService extends OCPPRequestService { ) return response } catch (error) { - logger.error( - `${chargingStation.logPrefix()} ${moduleName}.requestHandler: Error processing '${commandName}' request:`, - error - ) + this.logRequestHandlerError(chargingStation, moduleName, commandName, error) throw error } } diff --git a/src/charging-station/ocpp/1.6/OCPP16ServiceUtils.ts b/src/charging-station/ocpp/1.6/OCPP16ServiceUtils.ts index 303b9daf..52011a7f 100644 --- a/src/charging-station/ocpp/1.6/OCPP16ServiceUtils.ts +++ b/src/charging-station/ocpp/1.6/OCPP16ServiceUtils.ts @@ -52,6 +52,7 @@ import { } from '../../../types/index.js' import { clampToSafeTimerValue, + Constants, convertToBoolean, convertToDate, convertToInt, @@ -223,7 +224,9 @@ export class OCPP16ServiceUtils { const sampledValueTemplate = getSampledValueTemplate(chargingStation, connectorId) if (sampledValueTemplate != null) { const unitDivider = - sampledValueTemplate.unit === OCPP16MeterValueUnit.KILO_WATT_HOUR ? 1000 : 1 + sampledValueTemplate.unit === OCPP16MeterValueUnit.KILO_WATT_HOUR + ? Constants.UNIT_DIVIDER_KILO + : 1 meterValue.sampledValue.push( buildOCPP16SampledValue( sampledValueTemplate, @@ -294,7 +297,10 @@ export class OCPP16ServiceUtils { `Missing MeterValues for default measurand '${OCPP16MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER}' in template on connector id ${connectorId.toString()}` ) } - const unitDivider = sampledValueTemplate.unit === OCPP16MeterValueUnit.KILO_WATT_HOUR ? 1000 : 1 + const unitDivider = + sampledValueTemplate.unit === OCPP16MeterValueUnit.KILO_WATT_HOUR + ? Constants.UNIT_DIVIDER_KILO + : 1 const meterValue = buildEmptyMeterValue() as OCPP16MeterValue meterValue.sampledValue.push( buildOCPP16SampledValue( diff --git a/src/charging-station/ocpp/2.0/OCPP20CertSigningRetryManager.ts b/src/charging-station/ocpp/2.0/OCPP20CertSigningRetryManager.ts index 3a4397c2..71549a5d 100644 --- a/src/charging-station/ocpp/2.0/OCPP20CertSigningRetryManager.ts +++ b/src/charging-station/ocpp/2.0/OCPP20CertSigningRetryManager.ts @@ -10,6 +10,7 @@ import { OCPP20RequestCommand, } from '../../../types/index.js' import { computeExponentialBackOffDelay, logger } from '../../../utils/index.js' +import { OCPP20Constants } from './OCPP20Constants.js' import { OCPP20ServiceUtils } from './OCPP20ServiceUtils.js' const moduleName = 'OCPP20CertSigningRetryManager' @@ -90,7 +91,7 @@ export class OCPP20CertSigningRetryManager { this.chargingStation, OCPP20ComponentName.SecurityCtrlr, OCPP20OptionalVariableName.CertSigningWaitMinimum, - 60 + OCPP20Constants.DEFAULT_CERT_SIGNING_WAIT_MINIMUM_SECONDS ) ) const delayMs = computeExponentialBackOffDelay({ diff --git a/src/charging-station/ocpp/2.0/OCPP20Constants.ts b/src/charging-station/ocpp/2.0/OCPP20Constants.ts index 48e8fed2..ce5af58e 100644 --- a/src/charging-station/ocpp/2.0/OCPP20Constants.ts +++ b/src/charging-station/ocpp/2.0/OCPP20Constants.ts @@ -140,8 +140,16 @@ export class OCPP20Constants extends OCPPConstants { // { from: OCPP20ConnectorStatusEnumType.Faulted, to: OCPP20ConnectorStatusEnumType.Faulted } ]) + static readonly DEFAULT_CERT_SIGNING_WAIT_MINIMUM_SECONDS = 60 + static readonly DEFAULT_CONNECTION_URL = 'ws://localhost' + static readonly DEFAULT_RETRY_BACKOFF_RANDOM_RANGE_SECONDS = 10 + + static readonly DEFAULT_RETRY_BACKOFF_REPEAT_TIMES = 5 + + static readonly DEFAULT_RETRY_BACKOFF_WAIT_MINIMUM_SECONDS = 30 + static readonly FIRMWARE_INSTALL_DELAY_MS = 5000 static readonly FIRMWARE_STATUS_DELAY_MS = 2000 static readonly FIRMWARE_VERIFY_DELAY_MS = 500 diff --git a/src/charging-station/ocpp/2.0/OCPP20RequestService.ts b/src/charging-station/ocpp/2.0/OCPP20RequestService.ts index 33e06555..381f75b4 100644 --- a/src/charging-station/ocpp/2.0/OCPP20RequestService.ts +++ b/src/charging-station/ocpp/2.0/OCPP20RequestService.ts @@ -129,10 +129,7 @@ export class OCPP20RequestService extends OCPPRequestService { ) return response } catch (error) { - logger.error( - `${chargingStation.logPrefix()} ${moduleName}.requestHandler: Error processing '${commandName}' request:`, - error - ) + this.logRequestHandlerError(chargingStation, moduleName, commandName, error) throw error } } diff --git a/src/charging-station/ocpp/2.0/OCPP20ServiceUtils.ts b/src/charging-station/ocpp/2.0/OCPP20ServiceUtils.ts index 4eb45de6..cedcd25b 100644 --- a/src/charging-station/ocpp/2.0/OCPP20ServiceUtils.ts +++ b/src/charging-station/ocpp/2.0/OCPP20ServiceUtils.ts @@ -65,6 +65,7 @@ import { createPayloadConfigs, PayloadValidatorOptions, } from '../OCPPServiceUtils.js' +import { OCPP20Constants } from './OCPP20Constants.js' import { mapStopReasonToOCPP20 } from './OCPP20RequestBuilders.js' import { OCPP20VariableManager } from './OCPP20VariableManager.js' @@ -236,19 +237,19 @@ export class OCPP20ServiceUtils { chargingStation, OCPP20ComponentName.OCPPCommCtrlr, OCPP20OptionalVariableName.RetryBackOffWaitMinimum, - 30 + OCPP20Constants.DEFAULT_RETRY_BACKOFF_WAIT_MINIMUM_SECONDS ) const randomRange = OCPP20ServiceUtils.readVariableAsInteger( chargingStation, OCPP20ComponentName.OCPPCommCtrlr, OCPP20OptionalVariableName.RetryBackOffRandomRange, - 10 + OCPP20Constants.DEFAULT_RETRY_BACKOFF_RANDOM_RANGE_SECONDS ) const repeatTimes = OCPP20ServiceUtils.readVariableAsInteger( chargingStation, OCPP20ComponentName.OCPPCommCtrlr, OCPP20OptionalVariableName.RetryBackOffRepeatTimes, - 5 + OCPP20Constants.DEFAULT_RETRY_BACKOFF_REPEAT_TIMES ) return computeExponentialBackOffDelay({ baseDelayMs: secondsToMilliseconds(waitMinimum), diff --git a/src/charging-station/ocpp/2.0/__testable__/index.ts b/src/charging-station/ocpp/2.0/__testable__/index.ts index 40629a6f..73480b98 100644 --- a/src/charging-station/ocpp/2.0/__testable__/index.ts +++ b/src/charging-station/ocpp/2.0/__testable__/index.ts @@ -69,7 +69,7 @@ import type { OCPP20IncomingRequestService } from '../OCPP20IncomingRequestServi * Interface exposing private handler methods of OCPP20IncomingRequestService for testing. * Each method signature matches the corresponding private method in the service class. */ -export interface TestableOCPP20IncomingRequestService { +interface TestableOCPP20IncomingRequestService { /** * Builds report data for the device model report. * Used internally by handleRequestGetBaseReport. @@ -313,11 +313,8 @@ export function createTestableIncomingRequestService ( export { createTestableRequestService, - type SendMessageFn, type SendMessageMock, type TestableOCPP20RequestService, - type TestableRequestServiceOptions, - type TestableRequestServiceResult, } from './OCPP20RequestServiceTestable.js' export { @@ -325,7 +322,4 @@ export { type TestableOCPP20ResponseService, } from './OCPP20ResponseServiceTestable.js' -export { - createTestableVariableManager, - type TestableOCPP20VariableManager, -} from './OCPP20VariableManagerTestable.js' +export { createTestableVariableManager } from './OCPP20VariableManagerTestable.js' diff --git a/src/charging-station/ocpp/OCPPRequestService.ts b/src/charging-station/ocpp/OCPPRequestService.ts index 46b4cf2a..4d30f9a7 100644 --- a/src/charging-station/ocpp/OCPPRequestService.ts +++ b/src/charging-station/ocpp/OCPPRequestService.ts @@ -139,6 +139,18 @@ export abstract class OCPPRequestService { } } + protected logRequestHandlerError ( + chargingStation: ChargingStation, + subclassModuleName: string, + commandName: RequestCommand, + error: unknown + ): void { + logger.error( + `${chargingStation.logPrefix()} ${subclassModuleName}.requestHandler: Error processing '${commandName}' request:`, + error + ) + } + protected async sendMessage ( chargingStation: ChargingStation, messageId: string, diff --git a/src/charging-station/ocpp/OCPPServiceUtils.ts b/src/charging-station/ocpp/OCPPServiceUtils.ts index fc37e9b5..d93196d1 100644 --- a/src/charging-station/ocpp/OCPPServiceUtils.ts +++ b/src/charging-station/ocpp/OCPPServiceUtils.ts @@ -91,8 +91,6 @@ import { const moduleName = 'OCPPServiceUtils' -const SOC_MAXIMUM_VALUE = 100 - const isOCPP20FlagEnabled = ( chargingStation: ChargingStation, component: OCPP20ComponentName, @@ -262,7 +260,7 @@ const buildSocMeasurandValue = ( return null } - const socMaximumValue = SOC_MAXIMUM_VALUE + const socMaximumValue = Constants.SOC_MAXIMUM_PERCENT const socMinimumValue = socSampledValueTemplate.minimumValue ?? 0 const socSampledValueTemplateValue = isNotEmptyString(socSampledValueTemplate.value) ? getRandomFloatFluctuatedRounded( @@ -1210,7 +1208,7 @@ export const buildMeterValue = ( connectorId, convertToInt(socSampledValue.value), socMeasurand.template.minimumValue ?? 0, - SOC_MAXIMUM_VALUE, + Constants.SOC_MAXIMUM_PERCENT, socSampledValue.measurand, debug ) diff --git a/src/charging-station/ocpp/OCPPSignedMeterDataGenerator.ts b/src/charging-station/ocpp/OCPPSignedMeterDataGenerator.ts index d416f012..cb4031fb 100644 --- a/src/charging-station/ocpp/OCPPSignedMeterDataGenerator.ts +++ b/src/charging-station/ocpp/OCPPSignedMeterDataGenerator.ts @@ -7,7 +7,7 @@ import { MeterValueUnit, SigningMethodEnumType, } from '../../types/index.js' -import { roundTo } from '../../utils/index.js' +import { Constants, roundTo } from '../../utils/index.js' export interface SignedMeterData extends JsonObject { encodingMethod: EncodingMethodEnumType @@ -53,7 +53,7 @@ export const generateSignedMeterData = ( const meterValueKwh = params.meterValueUnit === MeterValueUnit.KILO_WATT_HOUR ? roundTo(params.meterValue, 3) - : roundTo(params.meterValue / 1000, 3) + : roundTo(params.meterValue / Constants.UNIT_DIVIDER_KILO, 3) const ocmfPayload = { FV: '1.0', diff --git a/src/charging-station/ocpp/auth/cache/InMemoryAuthCache.ts b/src/charging-station/ocpp/auth/cache/InMemoryAuthCache.ts index 692e2f9a..9f78ed4e 100644 --- a/src/charging-station/ocpp/auth/cache/InMemoryAuthCache.ts +++ b/src/charging-station/ocpp/auth/cache/InMemoryAuthCache.ts @@ -1,4 +1,4 @@ -import { secondsToMilliseconds } from 'date-fns' +import { millisecondsToSeconds, secondsToMilliseconds } from 'date-fns' import type { AuthCache, CacheStats } from '../interfaces/OCPPAuthService.js' import type { AuthorizationResult } from '../types/AuthTypes.js' @@ -65,9 +65,6 @@ interface RateLimitStats { * - Bounded rate limits map prevents unbounded memory growth */ export class InMemoryAuthCache implements AuthCache { - // Implementation-specific safety limit (not mandated by OCPP spec) - private static readonly DEFAULT_MAX_ABSOLUTE_LIFETIME_MS = 86_400_000 // 24 hours - /** Cache storage: identifier -> entry */ private readonly cache = new Map() @@ -108,14 +105,14 @@ export class InMemoryAuthCache implements AuthCache { /** * Create an in-memory auth cache * @param options - Cache configuration options - * @param options.cleanupIntervalSeconds - Periodic cleanup interval in seconds (default: 300, 0 to disable) - * @param options.defaultTtl - Default TTL in seconds (default: 3600) - * @param options.maxAbsoluteLifetimeMs - Absolute lifetime cap in milliseconds (default: 86400000) + * @param options.cleanupIntervalSeconds - Periodic cleanup interval in seconds (default: Constants.DEFAULT_AUTH_CACHE_CLEANUP_INTERVAL_SECONDS, 0 to disable) + * @param options.defaultTtl - Default TTL in seconds (default: Constants.DEFAULT_AUTH_CACHE_TTL_SECONDS) + * @param options.maxAbsoluteLifetimeMs - Absolute lifetime cap in milliseconds (default: Constants.DEFAULT_AUTH_CACHE_MAX_ABSOLUTE_LIFETIME_MS) * @param options.maxEntries - Maximum number of cache entries (default: Constants.DEFAULT_AUTH_CACHE_MAX_ENTRIES) * @param options.rateLimit - Rate limiting configuration * @param options.rateLimit.enabled - Enable rate limiting (default: false) - * @param options.rateLimit.maxRequests - Max requests per window (default: 10) - * @param options.rateLimit.windowMs - Time window in milliseconds (default: 60000) + * @param options.rateLimit.maxRequests - Max requests per window (default: Constants.DEFAULT_AUTH_CACHE_RATE_LIMIT_MAX_REQUESTS) + * @param options.rateLimit.windowMs - Time window in milliseconds (default: Constants.DEFAULT_AUTH_CACHE_RATE_LIMIT_WINDOW_MS) */ constructor (options?: { cleanupIntervalSeconds?: number @@ -126,7 +123,7 @@ export class InMemoryAuthCache implements AuthCache { }) { this.defaultTtl = options?.defaultTtl ?? Constants.DEFAULT_AUTH_CACHE_TTL_SECONDS this.maxAbsoluteLifetimeMs = - options?.maxAbsoluteLifetimeMs ?? InMemoryAuthCache.DEFAULT_MAX_ABSOLUTE_LIFETIME_MS + options?.maxAbsoluteLifetimeMs ?? Constants.DEFAULT_AUTH_CACHE_MAX_ABSOLUTE_LIFETIME_MS this.maxEntries = Math.max(1, options?.maxEntries ?? Constants.DEFAULT_AUTH_CACHE_MAX_ENTRIES) this.rateLimit = { enabled: options?.rateLimit?.enabled ?? false, @@ -341,7 +338,7 @@ export class InMemoryAuthCache implements AuthCache { } const ttlSeconds = ttl ?? this.defaultTtl - const maxTtlSeconds = this.maxAbsoluteLifetimeMs / 1000 + const maxTtlSeconds = millisecondsToSeconds(this.maxAbsoluteLifetimeMs) const clampedTtl = Math.min(Math.max(0, ttlSeconds), maxTtlSeconds) const now = Date.now() const expiresAt = now + secondsToMilliseconds(clampedTtl) diff --git a/src/charging-station/ocpp/auth/index.ts b/src/charging-station/ocpp/auth/index.ts index a7c71129..b844de1a 100644 --- a/src/charging-station/ocpp/auth/index.ts +++ b/src/charging-station/ocpp/auth/index.ts @@ -12,11 +12,7 @@ export { InMemoryLocalAuthListManager } from './cache/InMemoryLocalAuthListManag export type { AuthCache, AuthComponentFactory, - AuthStats, AuthStrategy, - CacheStats, - CertificateAuthProvider, - CertificateInfo, DifferentialAuthEntry, LocalAuthEntry, LocalAuthListManager, diff --git a/src/charging-station/ocpp/auth/strategies/RemoteAuthStrategy.ts b/src/charging-station/ocpp/auth/strategies/RemoteAuthStrategy.ts index 8252dc84..d8bf79d1 100644 --- a/src/charging-station/ocpp/auth/strategies/RemoteAuthStrategy.ts +++ b/src/charging-station/ocpp/auth/strategies/RemoteAuthStrategy.ts @@ -10,6 +10,7 @@ import type { import type { AuthConfiguration, AuthorizationResult, AuthRequest } from '../types/AuthTypes.js' import { + Constants, ensureError, getErrorMessage, logger, @@ -382,8 +383,7 @@ export class RemoteAuthStrategy implements AuthStrategy { } try { - // Use provided TTL or default cache lifetime - const cacheTtl = ttl ?? result.cacheTtl ?? 300 // Default 5 minutes + const cacheTtl = ttl ?? result.cacheTtl ?? Constants.DEFAULT_REMOTE_AUTH_CACHE_TTL_SECONDS this.authCache.set(identifier, result, cacheTtl) logger.debug( `${moduleName}: Cached result for '${truncateId(identifier)}' (TTL: ${String(cacheTtl)}s)` diff --git a/src/charging-station/ocpp/auth/utils/ConfigValidator.ts b/src/charging-station/ocpp/auth/utils/ConfigValidator.ts index 8370e4e7..70d2972e 100644 --- a/src/charging-station/ocpp/auth/utils/ConfigValidator.ts +++ b/src/charging-station/ocpp/auth/utils/ConfigValidator.ts @@ -1,8 +1,14 @@ -import { isNotEmptyArray, logger } from '../../../../utils/index.js' +import { Constants, isNotEmptyArray, logger } from '../../../../utils/index.js' import { type AuthConfiguration, AuthenticationError, AuthErrorCode } from '../types/AuthTypes.js' const moduleName = 'AuthConfigValidator' +const AUTH_CACHE_TTL_MIN_SECONDS = 60 +const AUTH_CACHE_TTL_MAX_SECONDS = Constants.SECONDS_PER_DAY +const AUTH_CACHE_MIN_ENTRIES = 10 +const AUTH_TIMEOUT_MIN_SECONDS = 5 +const AUTH_TIMEOUT_MAX_SECONDS = 60 + /** * Warn if no authentication method is enabled in the configuration. * @param config - Authentication configuration to check @@ -73,13 +79,13 @@ function validateCacheConfig (config: AuthConfiguration): void { ) } - if (config.authorizationCacheLifetime < 60) { + if (config.authorizationCacheLifetime < AUTH_CACHE_TTL_MIN_SECONDS) { logger.warn( - `${moduleName}: authorizationCacheLifetime is very short (${String(config.authorizationCacheLifetime)}s). Consider using at least 60s for efficiency.` + `${moduleName}: authorizationCacheLifetime is very short (${String(config.authorizationCacheLifetime)}s). Consider using at least ${String(AUTH_CACHE_TTL_MIN_SECONDS)}s for efficiency.` ) } - if (config.authorizationCacheLifetime > 86400) { + if (config.authorizationCacheLifetime > AUTH_CACHE_TTL_MAX_SECONDS) { logger.warn( `${moduleName}: authorizationCacheLifetime is very long (${String(config.authorizationCacheLifetime)}s). This may lead to stale authorizations.` ) @@ -101,7 +107,7 @@ function validateCacheConfig (config: AuthConfiguration): void { ) } - if (config.maxCacheEntries < 10) { + if (config.maxCacheEntries < AUTH_CACHE_MIN_ENTRIES) { logger.warn( `${moduleName}: maxCacheEntries is very small (${String(config.maxCacheEntries)}). Cache may be ineffective with frequent evictions.` ) @@ -172,13 +178,13 @@ function validateTimeout (config: AuthConfiguration): void { ) } - if (config.authorizationTimeout < 5) { + if (config.authorizationTimeout < AUTH_TIMEOUT_MIN_SECONDS) { logger.warn( `${moduleName}: authorizationTimeout is very short (${String(config.authorizationTimeout)}s). This may cause premature timeouts.` ) } - if (config.authorizationTimeout > 60) { + if (config.authorizationTimeout > AUTH_TIMEOUT_MAX_SECONDS) { logger.warn( `${moduleName}: authorizationTimeout is very long (${String(config.authorizationTimeout)}s). Users may experience long waits.` ) diff --git a/src/charging-station/ui-server/AbstractUIServer.ts b/src/charging-station/ui-server/AbstractUIServer.ts index 76f25639..97ff29f0 100644 --- a/src/charging-station/ui-server/AbstractUIServer.ts +++ b/src/charging-station/ui-server/AbstractUIServer.ts @@ -1,5 +1,6 @@ import type { WebSocket } from 'ws' +import { millisecondsToSeconds } from 'date-fns' import { getReasonPhrase, StatusCodes } from 'http-status-codes' import { type IncomingMessage, Server, type ServerResponse } from 'node:http' import { createServer, type Http2Server } from 'node:http2' @@ -970,7 +971,7 @@ export abstract class AbstractUIServer { provider, 'simulator_station_data_timestamp_seconds', 'Unix epoch (seconds) at which the charging station snapshot was emitted.', - data => Math.floor(data.timestamp / 1000) + data => Math.floor(millisecondsToSeconds(data.timestamp)) ) addPerStationStatusInfo( @@ -1147,7 +1148,9 @@ export abstract class AbstractUIServer { 'simulator_connector_transaction_start_seconds', 'Unix epoch (seconds) at which the active transaction started on the connector.', cs => - cs.transactionStart != null ? Math.floor(cs.transactionStart.getTime() / 1000) : undefined + cs.transactionStart != null + ? Math.floor(millisecondsToSeconds(cs.transactionStart.getTime())) + : undefined ) addConnectorNumeric( registry, diff --git a/src/charging-station/ui-server/UIServerAccessPolicy.ts b/src/charging-station/ui-server/UIServerAccessPolicy.ts index c504af81..dc693e78 100644 --- a/src/charging-station/ui-server/UIServerAccessPolicy.ts +++ b/src/charging-station/ui-server/UIServerAccessPolicy.ts @@ -2,7 +2,6 @@ import type { IncomingMessage } from 'node:http' import type { UIServerConfiguration } from '../../types/index.js' -import { UI_SERVER_ACCESS_POLICY_DEFAULTS } from '../../utils/ConfigurationSchema.js' import { has, isEmpty, @@ -10,6 +9,7 @@ import { isNotEmptyArray, normalizeHost, normalizeIPAddress, + UI_SERVER_ACCESS_POLICY_DEFAULTS, } from '../../utils/index.js' import { splitHeaderList, splitQuoted } from './UIServerNet.js' diff --git a/src/charging-station/ui-server/UIServerFactory.ts b/src/charging-station/ui-server/UIServerFactory.ts index 015a1887..afcc1231 100644 --- a/src/charging-station/ui-server/UIServerFactory.ts +++ b/src/charging-station/ui-server/UIServerFactory.ts @@ -82,7 +82,7 @@ export class UIServerFactory { logger.warn( `${UIServerFactory.logPrefix(moduleName, 'getUIServerImplementation')} ${logMsg}` ) - // eslint-disable-next-line @typescript-eslint/no-deprecated + // eslint-disable-next-line @typescript-eslint/no-deprecated -- UIHttpServer is @deprecated but remains selectable via configuration until removal return new UIHttpServer(uiServerConfiguration, bootstrap) } case ApplicationProtocol.MCP: diff --git a/src/charging-station/ui-server/UIWebSocketServer.ts b/src/charging-station/ui-server/UIWebSocketServer.ts index be82f942..7954c68d 100644 --- a/src/charging-station/ui-server/UIWebSocketServer.ts +++ b/src/charging-station/ui-server/UIWebSocketServer.ts @@ -34,6 +34,12 @@ import { const moduleName = 'UIWebSocketServer' +const WS_DEFLATE_CONCURRENCY_LIMIT = 10 +const WS_DEFLATE_SERVER_MAX_WINDOW_BITS = 12 +const WS_DEFLATE_ZLIB_CHUNK_SIZE_BYTES = 16 * 1024 +const WS_DEFLATE_ZLIB_COMPRESSION_LEVEL = 6 +const WS_DEFLATE_ZLIB_MEM_LEVEL = 7 + // Pre-handshake WS rejections write raw HTTP/1.1 to the Duplex socket; // AbstractUIServer.renderDenial targets ServerResponse and is not applicable. const buildUpgradeRejectionResponse = ( @@ -68,17 +74,17 @@ export class UIWebSocketServer extends AbstractUIServer { noServer: true, perMessageDeflate: { clientNoContextTakeover: true, - concurrencyLimit: 10, - serverMaxWindowBits: 12, + concurrencyLimit: WS_DEFLATE_CONCURRENCY_LIMIT, + serverMaxWindowBits: WS_DEFLATE_SERVER_MAX_WINDOW_BITS, serverNoContextTakeover: true, threshold: DEFAULT_COMPRESSION_THRESHOLD_BYTES, zlibDeflateOptions: { - chunkSize: 16 * 1024, - level: 6, - memLevel: 7, + chunkSize: WS_DEFLATE_ZLIB_CHUNK_SIZE_BYTES, + level: WS_DEFLATE_ZLIB_COMPRESSION_LEVEL, + memLevel: WS_DEFLATE_ZLIB_MEM_LEVEL, }, zlibInflateOptions: { - chunkSize: 16 * 1024, + chunkSize: WS_DEFLATE_ZLIB_CHUNK_SIZE_BYTES, }, }, }) diff --git a/src/charging-station/ui-server/mcp/index.ts b/src/charging-station/ui-server/mcp/index.ts index 434a14de..5e269d1a 100644 --- a/src/charging-station/ui-server/mcp/index.ts +++ b/src/charging-station/ui-server/mcp/index.ts @@ -1,4 +1,3 @@ export { registerMCPResources, registerMCPSchemaResources } from './MCPResources.js' export { registerMCPLogTools } from './MCPTools.js' export { mcpToolSchemas, ocppSchemaMapping } from './MCPToolSchemas.js' -export type { MCPToolSchema } from './MCPToolSchemas.js' diff --git a/src/performance/PerformanceStatistics.ts b/src/performance/PerformanceStatistics.ts index 79a94450..d19aebb3 100644 --- a/src/performance/PerformanceStatistics.ts +++ b/src/performance/PerformanceStatistics.ts @@ -41,6 +41,8 @@ import { const moduleName = 'PerformanceStatistics' +const STATISTICS_PERCENTILE = 95 + export class PerformanceStatistics { private static readonly instances: Map = new Map< string, @@ -241,7 +243,7 @@ export class PerformanceStatistics { entryStatisticsData.medTimeMeasurement = median(timeMeasurementValues) entryStatisticsData.ninetyFiveThPercentileTimeMeasurement = percentile( timeMeasurementValues, - 95 + STATISTICS_PERCENTILE ) entryStatisticsData.stdTimeMeasurement = std( timeMeasurementValues, diff --git a/src/utils/Constants.ts b/src/utils/Constants.ts index 5cd04899..9fd769e3 100644 --- a/src/utils/Constants.ts +++ b/src/utils/Constants.ts @@ -6,6 +6,11 @@ import { VendorParametersKey, } from '../types/index.js' +// Shared literals for class-static members below (TS2729 forbids static +// forward-reference). +const DAY_IN_MS = 86_400_000 +const DAY_IN_SECONDS = 86_400 + // eslint-disable-next-line @typescript-eslint/no-extraneous-class export class Constants { static readonly DEFAULT_ATG_CONFIGURATION: Readonly = @@ -24,6 +29,9 @@ export class Constants { static readonly DEFAULT_AUTH_CACHE_CLEANUP_INTERVAL_SECONDS = 300 + /** Implementation-specific upper bound for auth cache entry absolute lifetime (24 hours). Not mandated by OCPP spec. */ + static readonly DEFAULT_AUTH_CACHE_MAX_ABSOLUTE_LIFETIME_MS = DAY_IN_MS + static readonly DEFAULT_AUTH_CACHE_MAX_ENTRIES = 1000 static readonly DEFAULT_AUTH_CACHE_RATE_LIMIT_MAX_REQUESTS = 10 @@ -64,6 +72,17 @@ export class Constants { static readonly DEFAULT_PERFORMANCE_RECORDS_DB_NAME = 'e-mobility-charging-stations-simulator' static readonly DEFAULT_PERFORMANCE_RECORDS_FILENAME = 'performanceRecords.json' + + /** + * Peak jitter fraction: consumers scale the base delay by a uniform draw in + * `[0, jitterPercent)`. See `computeExponentialBackOffDelay` (uni-directional + * positive) and `randomizeDelay` (asymmetric with a probability-zero gap). + */ + static readonly DEFAULT_RECONNECT_JITTER_PERCENT = 0.2 + + /** Default cache TTL for remote authorization results, distinct from `DEFAULT_AUTH_CACHE_TTL_SECONDS` (3600, local cache default). */ + static readonly DEFAULT_REMOTE_AUTH_CACHE_TTL_SECONDS = 300 + static readonly DEFAULT_STATION_INFO: Readonly> = Object.freeze({ automaticTransactionGeneratorPersistentConfiguration: true, autoReconnectMaxRetries: -1, @@ -126,15 +145,30 @@ export class Constants { // Values exceeding this limit cause Node.js to reset the delay to 1ms static readonly MAX_SETINTERVAL_DELAY_MS = 2147483647 + /** Milliseconds per day; equal to `24 * MS_PER_HOUR`. */ + static readonly MS_PER_DAY = DAY_IN_MS + /** Milliseconds per hour; conversion factor for `Wh` accrual from `W·ms`. */ static readonly MS_PER_HOUR = 3_600_000 static readonly PERFORMANCE_RECORDS_TABLE = 'performance_records' + /** Seconds per day; used for day-normalized time arithmetic. */ + static readonly SECONDS_PER_DAY = DAY_IN_SECONDS + + /** State of Charge maximum percentage (upper bound for SoC measurand emission). */ + static readonly SOC_MAXIMUM_PERCENT = 100 + static readonly STOP_CHARGING_STATIONS_TIMEOUT_MS = 60000 static readonly STOP_MESSAGE_SEQUENCE_TIMEOUT_MS = 30000 - /** Divider between base units (W, Wh) and kilo units (kW, kWh). */ + /** Divider between base units (A) and centi units (cA). */ + static readonly UNIT_DIVIDER_CENTI = 100 + + /** Divider between base units (A) and deci units (dA). */ + static readonly UNIT_DIVIDER_DECI = 10 + + /** Divider between base units (W, Wh, A) and kilo/milli units (kW, kWh, mA). */ static readonly UNIT_DIVIDER_KILO = 1000 } diff --git a/src/utils/StatisticUtils.ts b/src/utils/StatisticUtils.ts index 7e99cedb..8155bc4e 100644 --- a/src/utils/StatisticUtils.ts +++ b/src/utils/StatisticUtils.ts @@ -49,7 +49,7 @@ export const percentile = (dataSet: number[], percentile: number): number => { } const base = (percentile / 100) * (sortedDataSet.length - 1) const baseIndex = Math.floor(base) - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- runtime out-of-bounds guard: baseIndex+1 can equal sortedDataSet.length; index access is typed as `number` without `noUncheckedIndexedAccess` if (sortedDataSet[baseIndex + 1] != null) { return ( sortedDataSet[baseIndex] + diff --git a/src/utils/Utils.ts b/src/utils/Utils.ts index 1f94218c..cd1361f6 100644 --- a/src/utils/Utils.ts +++ b/src/utils/Utils.ts @@ -195,14 +195,14 @@ export const formatDurationMilliSeconds = (duration: number): string => { if (duration < 0) { throw new RangeError('Duration cannot be negative') } - const days = Math.floor(duration / (24 * 3600 * 1000)) + const days = Math.floor(duration / Constants.MS_PER_DAY) const hours = Math.floor(millisecondsToHours(duration) - days * 24) const minutes = Math.floor( millisecondsToMinutes(duration) - days * 24 * 60 - hoursToMinutes(hours) ) const seconds = Math.floor( millisecondsToSeconds(duration) - - days * 24 * 3600 - + days * Constants.SECONDS_PER_DAY - hoursToSeconds(hours) - minutesToSeconds(minutes) ) @@ -385,7 +385,7 @@ export const clone = (object: T): T => { type AsyncFunctionType = (...args: A) => PromiseLike -// eslint-disable-next-line @typescript-eslint/no-empty-function +// eslint-disable-next-line @typescript-eslint/no-empty-function -- intentional no-op async lambda used only to capture its constructor for isAsyncFunction const AsyncFunctionConstructor = (async () => {}).constructor /** diff --git a/src/utils/index.ts b/src/utils/index.ts index 435a341f..18620063 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -9,11 +9,8 @@ export { } from './ChargingStationConfigurationUtils.js' export { Configuration, DEFAULT_PERSIST_STATE } from './Configuration.js' export { - applyConfigurationMigration, - coerceConfigurationVersion, CURRENT_CONFIGURATION_SCHEMA_VERSION, DEPRECATED_KEY_REMAPPINGS, - type FieldError, remapDeprecatedKeys, } from './ConfigurationMigrations.js' export { @@ -103,7 +100,6 @@ export { mergeDeepRight, once, promiseWithTimeout, - queueMicrotaskErrorThrowing, roundTo, secureRandom, sleep, diff --git a/src/worker/WorkerUtils.ts b/src/worker/WorkerUtils.ts index 0ff59557..bd521f98 100644 --- a/src/worker/WorkerUtils.ts +++ b/src/worker/WorkerUtils.ts @@ -1,6 +1,14 @@ import chalk from 'chalk' import { getRandomValues } from 'node:crypto' +/** + * Peak jitter fraction for randomized worker delays. Mirrors + * `Constants.DEFAULT_RECONNECT_JITTER_PERCENT`; `src/worker/` has no + * cross-module value imports. See `randomizeDelay` for the asymmetric + * distribution `(−10 %, 0] ∪ [+10 %, +20 %)`. + */ +const JITTER_PERCENT = 0.2 + const isPlainObject = (value: unknown): value is Record => { if (typeof value !== 'object' || value === null) return false return Object.prototype.toString.call(value).slice(8, -1) === 'Object' @@ -49,7 +57,7 @@ export const defaultErrorHandler = (error: Error): void => { export const randomizeDelay = (delay: number): number => { const random = secureRandom() const sign = random < 0.5 ? -1 : 1 - const randomSum = delay * 0.2 * random // 0-20% of the delay + const randomSum = delay * JITTER_PERCENT * random return delay + sign * randomSum } diff --git a/tests/charging-station/ChargingStation-Configuration.test.ts b/tests/charging-station/ChargingStation-Configuration.test.ts index ad7b0c2e..a3c1b24d 100644 --- a/tests/charging-station/ChargingStation-Configuration.test.ts +++ b/tests/charging-station/ChargingStation-Configuration.test.ts @@ -1,3 +1,4 @@ +import { secondsToMilliseconds } from 'date-fns' /** * @file Tests for ChargingStation Configuration Management * @description Unit tests for boot notification, config persistence, and WebSocket handling @@ -8,6 +9,7 @@ import { afterEach, beforeEach, describe, it } from 'node:test' import type { ChargingStation } from '../../src/charging-station/index.js' import { AvailabilityType, RegistrationStatusEnumType } from '../../src/types/index.js' +import { Constants } from '../../src/utils/index.js' import { standardCleanup, withMockTimers } from '../helpers/TestLifecycleHelpers.js' import { TEST_HEARTBEAT_INTERVAL_MS, TEST_ONE_HOUR_MS } from './ChargingStationTestConstants.js' import { cleanupChargingStation, createMockChargingStation } from './helpers/StationHelpers.js' @@ -76,7 +78,7 @@ await describe('ChargingStation Configuration Management', async () => { station = result.station // Assert - Heartbeat interval should match response interval - assert.strictEqual(station.getHeartbeatInterval(), customInterval * 1000) + assert.strictEqual(station.getHeartbeatInterval(), secondsToMilliseconds(customInterval)) assert.strictEqual(station.inPendingState(), true) }) @@ -215,7 +217,10 @@ await describe('ChargingStation Configuration Management', async () => { // Assert - Both should have their respective intervals assert.strictEqual(pendingStation.station.inPendingState(), true) assert.strictEqual(rejectedStation.station.inRejectedState(), true) - assert.strictEqual(pendingStation.station.getHeartbeatInterval(), 60000) + assert.strictEqual( + pendingStation.station.getHeartbeatInterval(), + Constants.DEFAULT_HEARTBEAT_INTERVAL_MS + ) assert.strictEqual(rejectedStation.station.getHeartbeatInterval(), TEST_ONE_HOUR_MS) // Cleanup @@ -265,16 +270,16 @@ await describe('ChargingStation Configuration Management', async () => { station = result.station // Act & Assert - should convert seconds to milliseconds - assert.strictEqual(station.getHeartbeatInterval(), 60000) + assert.strictEqual(station.getHeartbeatInterval(), Constants.DEFAULT_HEARTBEAT_INTERVAL_MS) }) - await it('should return default heartbeat interval when not explicitly configured', () => { - // Arrange - use default heartbeat interval (TEST_HEARTBEAT_INTERVAL_SECONDS = 60) + await it('should return default heartbeat interval when not set', () => { + // Arrange const result = createMockChargingStation() station = result.station - // Act & Assert - default 60s * 1000 = 60000ms - assert.strictEqual(station.getHeartbeatInterval(), 60000) + // Act & Assert - default 60s in ms + assert.strictEqual(station.getHeartbeatInterval(), Constants.DEFAULT_HEARTBEAT_INTERVAL_MS) }) await it('should return connection timeout in milliseconds', () => { @@ -337,15 +342,15 @@ await describe('ChargingStation Configuration Management', async () => { const result = createMockChargingStation({ heartbeatInterval: 60 }) station = result.station const initialInterval = station.getHeartbeatInterval() - assert.strictEqual(initialInterval, 60000) + assert.strictEqual(initialInterval, Constants.DEFAULT_HEARTBEAT_INTERVAL_MS) // Act - simulate configuration change by creating new station with different interval const result2 = createMockChargingStation({ heartbeatInterval: 120 }) const station2 = result2.station // Assert - different configurations have different intervals - assert.strictEqual(station2.getHeartbeatInterval(), 120000) - assert.strictEqual(station.getHeartbeatInterval(), 60000) // Original unchanged + assert.strictEqual(station2.getHeartbeatInterval(), secondsToMilliseconds(120)) + assert.strictEqual(station.getHeartbeatInterval(), Constants.DEFAULT_HEARTBEAT_INTERVAL_MS) // Original unchanged // Cleanup second station cleanupChargingStation(station2) diff --git a/tests/charging-station/ocpp/1.6/OCPP16ServiceUtils-SignedMeterValues.test.ts b/tests/charging-station/ocpp/1.6/OCPP16ServiceUtils-SignedMeterValues.test.ts index 22beb41c..b6975cdd 100644 --- a/tests/charging-station/ocpp/1.6/OCPP16ServiceUtils-SignedMeterValues.test.ts +++ b/tests/charging-station/ocpp/1.6/OCPP16ServiceUtils-SignedMeterValues.test.ts @@ -5,6 +5,7 @@ * buildSignedOCPP16SampledValue, and periodic meter values. */ +import { minutesToMilliseconds } from 'date-fns' import assert from 'node:assert/strict' import { afterEach, beforeEach, describe, it } from 'node:test' @@ -456,7 +457,7 @@ await describe('OCPP 1.6 — Signed MeterValues', async () => { // Act OCPP16ServiceUtils.startUpdatedMeterValues(station, 1, 60) - t.mock.timers.tick(60000) + t.mock.timers.tick(minutesToMilliseconds(1)) // Assert assert.ok(capturedMeterValue != null) @@ -481,7 +482,7 @@ await describe('OCPP 1.6 — Signed MeterValues', async () => { // Act OCPP16ServiceUtils.startUpdatedMeterValues(station, 1, 60) - t.mock.timers.tick(60000) + t.mock.timers.tick(minutesToMilliseconds(1)) // Assert assert.ok(capturedMeterValue != null) diff --git a/tests/charging-station/ocpp/2.0/OCPP20CertSigningRetryManager.test.ts b/tests/charging-station/ocpp/2.0/OCPP20CertSigningRetryManager.test.ts index 6861983b..8f382ecc 100644 --- a/tests/charging-station/ocpp/2.0/OCPP20CertSigningRetryManager.test.ts +++ b/tests/charging-station/ocpp/2.0/OCPP20CertSigningRetryManager.test.ts @@ -3,6 +3,7 @@ * @description Verifies certificate signing retry lifecycle per OCPP 2.0.1 A02.FR.17-19 */ +import { minutesToMilliseconds } from 'date-fns' import assert from 'node:assert/strict' import { afterEach, beforeEach, describe, it, mock } from 'node:test' @@ -112,7 +113,7 @@ await describe('OCPP20CertSigningRetryManager', async () => { // Act manager.startRetryTimer() - t.mock.timers.tick(60000) + t.mock.timers.tick(minutesToMilliseconds(1)) await flushMicrotasks() // Assert @@ -132,7 +133,7 @@ await describe('OCPP20CertSigningRetryManager', async () => { ) manager.startRetryTimer() - t.mock.timers.tick(60000) + t.mock.timers.tick(minutesToMilliseconds(1)) await flushMicrotasks() assert.strictEqual(requestHandlerMock.mock.callCount(), 0) @@ -163,7 +164,7 @@ await describe('OCPP20CertSigningRetryManager', async () => { // Act manager.cancelRetryTimer() - t.mock.timers.tick(60000) + t.mock.timers.tick(minutesToMilliseconds(1)) await flushMicrotasks() // Assert @@ -193,7 +194,7 @@ await describe('OCPP20CertSigningRetryManager', async () => { await flushMicrotasks() // Assert — no further retry despite Accepted response - t.mock.timers.tick(60000) + t.mock.timers.tick(minutesToMilliseconds(1)) await flushMicrotasks() assert.strictEqual(requestHandlerMock.mock.callCount(), 1) }) @@ -257,7 +258,7 @@ await describe('OCPP20CertSigningRetryManager', async () => { assert.strictEqual(requestHandlerMock.mock.callCount(), 2) // Assert — no third retry, max reached - t.mock.timers.tick(60000) + t.mock.timers.tick(minutesToMilliseconds(1)) await flushMicrotasks() assert.strictEqual(requestHandlerMock.mock.callCount(), 2) }) @@ -304,7 +305,7 @@ await describe('OCPP20CertSigningRetryManager', async () => { // Assert assert.strictEqual(requestHandlerMock.mock.callCount(), 1) - t.mock.timers.tick(60000) + t.mock.timers.tick(minutesToMilliseconds(1)) await flushMicrotasks() assert.strictEqual(requestHandlerMock.mock.callCount(), 1) }) diff --git a/tests/charging-station/ui-server/UIServerSecurity.test.ts b/tests/charging-station/ui-server/UIServerSecurity.test.ts index aaa28c7d..9ed84f0e 100644 --- a/tests/charging-station/ui-server/UIServerSecurity.test.ts +++ b/tests/charging-station/ui-server/UIServerSecurity.test.ts @@ -5,6 +5,7 @@ import type { IncomingMessage } from 'node:http' +import { minutesToMilliseconds } from 'date-fns' import assert from 'node:assert/strict' import { Readable } from 'node:stream' import { afterEach, describe, it } from 'node:test' @@ -113,7 +114,7 @@ await describe('UIServerSecurity', async () => { }) await it('should reject new IPs when at max tracked capacity', () => { - limiter = createRateLimiter(10, 60000, 3) + limiter = createRateLimiter(10, minutesToMilliseconds(1), 3) assert.strictEqual(limiter('192.168.1.1'), true) assert.strictEqual(limiter('192.168.1.2'), true) @@ -122,7 +123,7 @@ await describe('UIServerSecurity', async () => { }) await it('should allow existing IPs when at max capacity', () => { - limiter = createRateLimiter(10, 60000, 2) + limiter = createRateLimiter(10, minutesToMilliseconds(1), 2) assert.strictEqual(limiter('192.168.1.1'), true) assert.strictEqual(limiter('192.168.1.2'), true) diff --git a/tests/performance/storage/StorageTestHelpers.ts b/tests/performance/storage/StorageTestHelpers.ts index ae7b63c3..276f0db7 100644 --- a/tests/performance/storage/StorageTestHelpers.ts +++ b/tests/performance/storage/StorageTestHelpers.ts @@ -1,5 +1,7 @@ import type { Statistics } from '../../../src/types/index.js' +import { TEST_SUPERVISION_URL } from '../../utils/TestNetworkConstants.js' + /** * @param id - Performance record identifier * @param name - Charging station name @@ -26,6 +28,6 @@ export function buildTestStatistics (id: string, name?: string): Statistics { id, name: name ?? `cs-${id}`, statisticsData: statsData, - uri: 'ws://localhost:8080', + uri: TEST_SUPERVISION_URL, } } diff --git a/tests/utils/ConfigurationMigrations.test.ts b/tests/utils/ConfigurationMigrations.test.ts index 63d0db75..ccb77145 100644 --- a/tests/utils/ConfigurationMigrations.test.ts +++ b/tests/utils/ConfigurationMigrations.test.ts @@ -14,12 +14,13 @@ import { CURRENT_CONFIGURATION_SCHEMA_VERSION, DEPRECATED_KEY_REMAPPINGS, remapDeprecatedKeys, -} from '../../src/utils/index.js' +} from '../../src/utils/ConfigurationMigrations.js' import { standardCleanup } from '../helpers/TestLifecycleHelpers.js' import { buildLegacyConfiguration, buildV0WithDeprecatedKeyCollision, } from './helpers/ConfigurationFixtures.js' +import { TEST_SUPERVISION_URL } from './TestNetworkConstants.js' await describe('ConfigurationMigrations', async () => { afterEach(() => { @@ -127,7 +128,7 @@ await describe('ConfigurationMigrations', async () => { logEnabled: true, logFile: '/logs/combined.log', stationTemplateURLs: [{ file: 'a.json', numberOfStations: 1 }], - supervisionURLs: 'ws://localhost:8080', + supervisionURLs: TEST_SUPERVISION_URL, workerProcess: 'workerSet', }) @@ -138,7 +139,7 @@ await describe('ConfigurationMigrations', async () => { assert.strictEqual((result.log as Record).enabled, true) assert.strictEqual((result.log as Record).file, '/logs/combined.log') assert.strictEqual((result.worker as Record).processType, 'workerSet') - assert.strictEqual(result.supervisionUrls, 'ws://localhost:8080') + assert.strictEqual(result.supervisionUrls, TEST_SUPERVISION_URL) assert.ok(Array.isArray(result.stationTemplateUrls)) assert.strictEqual(result.logEnabled, undefined) assert.strictEqual(result.logFile, undefined) diff --git a/tests/utils/MessageChannelUtils.test.ts b/tests/utils/MessageChannelUtils.test.ts index ae35f7bb..da5d86cd 100644 --- a/tests/utils/MessageChannelUtils.test.ts +++ b/tests/utils/MessageChannelUtils.test.ts @@ -24,6 +24,7 @@ import { createMockChargingStation, } from '../charging-station/helpers/StationHelpers.js' import { standardCleanup } from '../helpers/TestLifecycleHelpers.js' +import { TEST_SUPERVISION_URL } from './TestNetworkConstants.js' await describe('MessageChannelUtils', async () => { let station: ChargingStation @@ -39,7 +40,7 @@ await describe('MessageChannelUtils', async () => { // Add wsConnectionUrl getter needed by MessageChannelUtils builders Object.defineProperty(station, 'wsConnectionUrl', { configurable: true, - get: () => new URL('ws://localhost:8080/CS-TEST-00001'), + get: () => new URL(`${TEST_SUPERVISION_URL}/CS-TEST-00001`), }) }) @@ -55,7 +56,7 @@ await describe('MessageChannelUtils', async () => { assert.notStrictEqual(message.data, undefined) assert.strictEqual(message.data.started, true) assert.strictEqual(message.data.stationInfo.chargingStationId, 'CS-TEST-00001') - assert.strictEqual(message.data.supervisionUrl, 'ws://localhost:8080/CS-TEST-00001') + assert.strictEqual(message.data.supervisionUrl, `${TEST_SUPERVISION_URL}/CS-TEST-00001`) assert.strictEqual(typeof message.data.timestamp, 'number') }) @@ -79,7 +80,7 @@ await describe('MessageChannelUtils', async () => { assert.strictEqual(message.event, ChargingStationWorkerMessageEvents.stopped) assert.notStrictEqual(message.data, undefined) - assert.strictEqual(message.data.supervisionUrl, 'ws://localhost:8080/CS-TEST-00001') + assert.strictEqual(message.data.supervisionUrl, `${TEST_SUPERVISION_URL}/CS-TEST-00001`) }) await it('should build updated message with correct event', () => { @@ -122,7 +123,7 @@ await describe('MessageChannelUtils', async () => { }, ], ]), - uri: 'ws://localhost:8080', + uri: TEST_SUPERVISION_URL, } const message = buildPerformanceStatisticsMessage(statistics) @@ -154,7 +155,7 @@ await describe('MessageChannelUtils', async () => { }, ], ]), - uri: 'ws://localhost:8080', + uri: TEST_SUPERVISION_URL, } const message = buildPerformanceStatisticsMessage(statistics) @@ -172,13 +173,13 @@ await describe('MessageChannelUtils', async () => { name: 'station-name', statisticsData: new Map(), updatedAt, - uri: 'ws://localhost:8080', + uri: TEST_SUPERVISION_URL, } const message = buildPerformanceStatisticsMessage(statistics) assert.strictEqual(message.data.createdAt, createdAt) assert.strictEqual(message.data.updatedAt, updatedAt) - assert.strictEqual(message.data.uri, 'ws://localhost:8080') + assert.strictEqual(message.data.uri, TEST_SUPERVISION_URL) }) }) diff --git a/tests/utils/TestNetworkConstants.ts b/tests/utils/TestNetworkConstants.ts new file mode 100644 index 00000000..b41b5ccf --- /dev/null +++ b/tests/utils/TestNetworkConstants.ts @@ -0,0 +1,10 @@ +/** + * @file Shared network-related test constants derived from production defaults. + * @description Single source of truth for the `ws://host:port` test URL, + * built from `Constants.DEFAULT_UI_SERVER_HOST` / + * `Constants.DEFAULT_UI_SERVER_PORT` so a change to the production defaults + * propagates automatically to the tests. + */ +import { Constants } from '../../src/utils/index.js' + +export const TEST_SUPERVISION_URL = `ws://${Constants.DEFAULT_UI_SERVER_HOST}:${Constants.DEFAULT_UI_SERVER_PORT.toString()}` diff --git a/tests/utils/Utils.test.ts b/tests/utils/Utils.test.ts index fae297f4..86a1b1d9 100644 --- a/tests/utils/Utils.test.ts +++ b/tests/utils/Utils.test.ts @@ -48,7 +48,6 @@ import { mergeDeepRight, once, promiseWithTimeout, - queueMicrotaskErrorThrowing, roundTo, secureRandom, sleep, @@ -56,6 +55,7 @@ import { validateIdentifierString, validateUUID, } from '../../src/utils/index.js' +import { queueMicrotaskErrorThrowing } from '../../src/utils/Utils.js' import { standardCleanup, withMockTimers } from '../helpers/TestLifecycleHelpers.js' await describe('Utils', async () => { diff --git a/tests/utils/helpers/ConfigurationFixtures.ts b/tests/utils/helpers/ConfigurationFixtures.ts index f85b6f24..a8db2e00 100644 --- a/tests/utils/helpers/ConfigurationFixtures.ts +++ b/tests/utils/helpers/ConfigurationFixtures.ts @@ -2,7 +2,8 @@ * @file Shared configuration fixtures for schema/migration/validation tests. */ -import { CURRENT_CONFIGURATION_SCHEMA_VERSION } from '../../../src/utils/index.js' +import { Constants, CURRENT_CONFIGURATION_SCHEMA_VERSION } from '../../../src/utils/index.js' +import { TEST_SUPERVISION_URL } from '../TestNetworkConstants.js' /** * Build a minimal valid configuration at the current schema version. @@ -60,10 +61,10 @@ export const buildFullConfiguration = (): Record => ({ { file: 'full.station-template.json', numberOfStations: 2, provisionedNumberOfStations: 4 }, ], supervisionUrlDistribution: 'charging-station-affinity', - supervisionUrls: ['ws://localhost:8080/ocpp'], + supervisionUrls: [`${TEST_SUPERVISION_URL}/ocpp`], uiServer: { enabled: false, - options: { host: 'localhost', port: 8080 }, + options: { host: Constants.DEFAULT_UI_SERVER_HOST, port: Constants.DEFAULT_UI_SERVER_PORT }, type: 'ws', version: '1.1', }, diff --git a/ui/cli/src/client/lifecycle.ts b/ui/cli/src/client/lifecycle.ts index 65667e63..7d5c2347 100644 --- a/ui/cli/src/client/lifecycle.ts +++ b/ui/cli/src/client/lifecycle.ts @@ -16,6 +16,11 @@ import type { Formatter } from '../output/formatter.js' import { createWsAdapter } from './ws-adapter.js' +// POSIX convention: signal-triggered exits use 128 + signal number +// (see ui/cli/README.md "Exit Codes" table for the canonical mapping) +const SIGINT_EXIT_CODE = 130 +const SIGTERM_EXIT_CODE = 143 + const wsFactory: WebSocketFactory = (url, protocols) => createWsAdapter(new WsWebSocket(url, protocols)) @@ -109,9 +114,9 @@ export const registerSignalHandlers = (): void => { } process.on('SIGINT', () => { - cleanup(130) + cleanup(SIGINT_EXIT_CODE) }) process.on('SIGTERM', () => { - cleanup(143) + cleanup(SIGTERM_EXIT_CODE) }) } diff --git a/ui/cli/src/output/format.ts b/ui/cli/src/output/format.ts index b7bcf229..2bb82843 100644 --- a/ui/cli/src/output/format.ts +++ b/ui/cli/src/output/format.ts @@ -1,6 +1,12 @@ import chalk from 'chalk' import Table from 'cli-table3' -import { type ConnectorEntry, type EvseEntry } from 'ui-common' +import { millisecondsToSeconds } from 'date-fns' +import { + type ConnectorEntry, + type EvseEntry, + OCPP16ChargePointStatus, + WebSocketReadyState, +} from 'ui-common' const NO_BORDER = { bottom: '', @@ -20,6 +26,10 @@ const NO_BORDER = { 'top-right': '', } +export const EMPTY_VALUE_PLACEHOLDER = '–' +const TEMPLATE_NAME_SUFFIX = '.station-template' +const TRUNCATED_HASH_ID_LENGTH = 12 + // cspell:ignore borderless export const borderlessTable = (head: string[], colWidths?: number[]): Table.Table => new Table({ @@ -29,7 +39,10 @@ export const borderlessTable = (head: string[], colWidths?: number[]): Table.Tab ...(colWidths != null && { colWidths }), }) -export const truncateId = (id: string, len = 12): string => +export const stripTemplateSuffix = (name: string): string => + name.endsWith(TEMPLATE_NAME_SUFFIX) ? name.slice(0, -TEMPLATE_NAME_SUFFIX.length) : name + +export const truncateId = (id: string, len = TRUNCATED_HASH_ID_LENGTH): string => id.length > len ? `${id.slice(0, len)}…` : id export const statusIcon = (started: boolean | undefined): string => @@ -37,22 +50,22 @@ export const statusIcon = (started: boolean | undefined): string => export const wsIcon = (wsState: number | undefined): string => { switch (wsState) { - case 0: + case WebSocketReadyState.CLOSED: + case WebSocketReadyState.CLOSING: + return chalk.red('✗') + case WebSocketReadyState.CONNECTING: return chalk.yellow('…') - case 1: + case WebSocketReadyState.OPEN: return chalk.green('✓') - case 2: - case 3: - return chalk.red('✗') default: - return chalk.dim('–') + return chalk.dim(EMPTY_VALUE_PLACEHOLDER) } } const STATUS_ABBREVIATIONS: Record = { - Finishing: 'Fi', - SuspendedEV: 'SE', - SuspendedEVSE: 'SS', + [OCPP16ChargePointStatus.FINISHING]: 'Fi', + [OCPP16ChargePointStatus.SUSPENDED_EV]: 'SE', + [OCPP16ChargePointStatus.SUSPENDED_EVSE]: 'SS', } const statusLetter = (status: string | undefined): string => { @@ -81,13 +94,13 @@ export const formatConnectors = (evses: EvseEntry[], connectors: ConnectorEntry[ } } - return entries.length > 0 ? chalk.dim(entries.join(' ')) : chalk.dim('–') + return entries.length > 0 ? chalk.dim(entries.join(' ')) : chalk.dim(EMPTY_VALUE_PLACEHOLDER) } export const fuzzyTime = (ts: number | undefined): string => { - if (ts == null) return chalk.dim('–') + if (ts == null) return chalk.dim(EMPTY_VALUE_PLACEHOLDER) const diff = Math.max(0, Date.now() - ts) - const seconds = Math.floor(diff / 1000) + const seconds = Math.floor(millisecondsToSeconds(diff)) if (seconds < 60) return chalk.dim('just now') const minutes = Math.floor(seconds / 60) if (minutes < 60) return chalk.dim(`${minutes.toString()}m ago`) diff --git a/ui/cli/src/output/renderers.ts b/ui/cli/src/output/renderers.ts index 5755ebfe..a98f42b3 100644 --- a/ui/cli/src/output/renderers.ts +++ b/ui/cli/src/output/renderers.ts @@ -2,14 +2,17 @@ import type { ResponsePayload } from 'ui-common' import chalk from 'chalk' import process from 'node:process' +import { WebSocketReadyState } from 'ui-common' import type { StationListPayload } from '../types.js' import { borderlessTable, + EMPTY_VALUE_PLACEHOLDER, formatConnectors, fuzzyTime, statusIcon, + stripTemplateSuffix, truncateId, wsIcon, } from './format.js' @@ -83,7 +86,9 @@ const renderSimulatorState = (payload: SimulatorStatePayload): void => { process.stdout.write(` Version ${state.version}\n`) if (state.configuration?.worker != null) { const w = state.configuration.worker - process.stdout.write(` Worker ${w.processType ?? '–'} (${w.elementsPerWorker ?? '–'})\n`) + process.stdout.write( + ` Worker ${w.processType ?? EMPTY_VALUE_PLACEHOLDER} (${w.elementsPerWorker ?? EMPTY_VALUE_PLACEHOLDER})\n` + ) } if ( state.configuration?.supervisionUrls != null && @@ -101,7 +106,7 @@ const renderSimulatorState = (payload: SimulatorStatePayload): void => { const table = borderlessTable(['Name', 'Added', 'Started', 'Provisioned', 'Configured']) for (const [name, s] of activeTemplates) { table.push([ - name.replace('.station-template', ''), + stripTemplateSuffix(name), s.added > 0 ? chalk.green(s.added.toString()) : chalk.dim('0'), s.started > 0 ? chalk.green(s.started.toString()) : chalk.dim('0'), s.provisioned > 0 ? s.provisioned.toString() : chalk.dim('0'), @@ -145,18 +150,18 @@ const renderStationList = (payload: StationListPayload): void => { statusIcon(cs.started), si.chargingStationId, chalk.dim(truncateId(si.hashId)), - reg ?? chalk.dim('–'), + reg ?? chalk.dim(EMPTY_VALUE_PLACEHOLDER), wsIcon(cs.wsState), formatConnectors(cs.evses ?? [], cs.connectors ?? []), - chalk.dim(si.ocppVersion ?? '–'), - chalk.dim(si.templateName.replace('.station-template', '')), + chalk.dim(si.ocppVersion ?? EMPTY_VALUE_PLACEHOLDER), + chalk.dim(stripTemplateSuffix(si.templateName)), fuzzyTime(cs.timestamp), ]) } process.stdout.write(`${table.toString()}\n`) const started = stations.filter(s => s.started).length - const connected = stations.filter(s => s.wsState === 1).length + const connected = stations.filter(s => s.wsState === WebSocketReadyState.OPEN).length process.stdout.write( chalk.dim( `\n${stations.length.toString()} station${stations.length !== 1 ? 's' : ''} (${started.toString()} started, ${connected.toString()} connected)\n` diff --git a/ui/web/src/core/Constants.ts b/ui/web/src/core/Constants.ts index d9a4c7fe..fd67cef4 100644 --- a/ui/web/src/core/Constants.ts +++ b/ui/web/src/core/Constants.ts @@ -8,6 +8,7 @@ export const ASYNC_COMPONENT_TIMEOUT_MS = 10_000 export const EMPTY_VALUE_PLACEHOLDER = 'Ø' export const MAX_SKIN_ERROR_RELOADS = 2 export const MAX_STATIONS_PER_ADD = 100 +export const SKIN_ERROR_RELOAD_COUNT_KEY = 'skin-error-reload-count' export const WH_PER_KWH = 1000 export const ROUTE_NAMES = { diff --git a/ui/web/src/core/index.ts b/ui/web/src/core/index.ts index f1f68c65..1072c494 100644 --- a/ui/web/src/core/index.ts +++ b/ui/web/src/core/index.ts @@ -8,6 +8,7 @@ export { MAX_STATIONS_PER_ADD, ROUTE_NAMES, SHARED_TOGGLE_BUTTON_KEY_PREFIX, + SKIN_ERROR_RELOAD_COUNT_KEY, TOGGLE_BUTTON_KEY_PREFIX, UI_SERVER_CONFIGURATION_INDEX_KEY, WH_PER_KWH, diff --git a/ui/web/src/shared/components/SkinLoadError.vue b/ui/web/src/shared/components/SkinLoadError.vue index a60ebf80..b4cbccba 100644 --- a/ui/web/src/shared/components/SkinLoadError.vue +++ b/ui/web/src/shared/components/SkinLoadError.vue @@ -11,7 +11,12 @@