* refactor: convention alignment round 2 — canonical defaults, helpers, dead exports, tests
Follow-up to #1950/#1952/#1953/#1954. Post-audit factorization; behavior-preserving.
Canonical defaults promoted to Constants.ts:
- UNIT_DIVIDER_CENTI/DECI (100/10); adopt UNIT_DIVIDER_KILO at 3 kW/kWh sites
- DEFAULT_RECONNECT_JITTER_PERCENT, SOC_MAXIMUM_PERCENT
- DEFAULT_AUTH_CACHE_MAX_ABSOLUTE_LIFETIME_MS
- DEFAULT_REMOTE_AUTH_CACHE_TTL_SECONDS
- MS_PER_DAY, SECONDS_PER_DAY (align with existing MS_PER_HOUR)
Canonical defaults promoted to OCPP20Constants.ts (OCPP-variable-read fallbacks):
- DEFAULT_RETRY_BACKOFF_{WAIT_MINIMUM,RANDOM_RANGE}_SECONDS
- DEFAULT_RETRY_BACKOFF_REPEAT_TIMES
- DEFAULT_CERT_SIGNING_WAIT_MINIMUM_SECONDS
Module-scope constants:
- WS_DEFLATE_{CONCURRENCY_LIMIT,SERVER_MAX_WINDOW_BITS,ZLIB_*} in UIWebSocketServer
- STATISTICS_PERCENTILE in PerformanceStatistics
- AUTH_CACHE_LIFETIME_{MIN,MAX}_SECONDS, AUTH_CACHE_MIN_ENTRIES,
AUTH_TIMEOUT_{MIN,MAX}_SECONDS in ConfigValidator
- JITTER_PERCENT local to src/worker/ (module standalone; no cross-dep)
- SIGINT/SIGTERM_EXIT_CODE, MISSING_VALUE_PLACEHOLDER,
TEMPLATE_NAME_SUFFIX, TRUNCATED_HASH_ID_LENGTH in ui/cli output
- SKIN_ERROR_RELOAD_COUNT_KEY in ui/web core
Helper adoption:
- millisecondsToSeconds (date-fns) at AbstractUIServer / InMemoryAuthCache / test
- isNotEmptyString in TemplateValidation
- WebSocketReadyState enum (ui-common) at CLI wsIcon/renderers and web stationStatus
- OCPP16ChargePointStatus / OCPP20ConnectorStatusEnumType enum keys instead
of string literals in CLI STATUS_ABBREVIATIONS and web CONNECTOR_STATUS_VARIANT
- nonEmptyStringOrUndefined shared helper (ui/web/src/shared/utils) adopted
by 3 composables + skin error extractor
- Extracted OCPPRequestService.logRequestHandlerError() consumed by OCPP16/20
RequestService (byte-identical catch shell)
Barrel/module hygiene:
- Close barrel gap: meter-values/index.ts exposes disposeCoherentSessionRuntime
(JSDoc already advertised it)
- UIServerAccessPolicy imports UI_SERVER_ACCESS_POLICY_DEFAULTS via utils/index
barrel (drop deep import)
- ui/web skins route 8 imports through @/shared/utils/index barrel
- UIServiceWorkerBroadcastChannel type-only imports AbstractUIService via
ui-server/index barrel (safe by type erasure; comment documents the constraint)
Dead barrel exports pruned (0 external consumers):
- src/utils: FieldError, applyConfigurationMigration, coerceConfigurationVersion,
queueMicrotaskErrorThrowing (tests updated to import from source)
- charging-station/meter-values: ChargingCurvePoint
- ui-server/mcp: MCPToolSchema
- ocpp/auth: AuthStats, CacheStats, CertificateAuthProvider, CertificateInfo
- ocpp/2.0/__testable__: SendMessageFn, TestableRequestServiceOptions/Result
re-exports; TestableOCPP20IncomingRequestService downgraded to module-private;
TestableOCPP20VariableManager type re-export dropped
Anchor-comment reasons added on 3 eslint-disable directives (Utils /
StatisticUtils / UIServerFactory).
Test constants:
- Heartbeat interval sites use Constants.DEFAULT_HEARTBEAT_INTERVAL_MS /
secondsToMilliseconds
- New tests/utils/TestNetworkConstants.ts: TEST_SUPERVISION_URL derived from
Constants.DEFAULT_UI_SERVER_{HOST,PORT}; adopted at MessageChannelUtils.test
+ StorageTestHelpers + ConfigurationFixtures
- Rate-limit / mock-timers-tick 60000 literals replaced by minutesToMilliseconds(1)
in UIServerSecurity / OCPP20CertSigningRetryManager / SignedMeterValues tests
Deliberately not applied (documented rationale):
- src/worker/WorkerAbstract isNotEmptyString adoption: src/worker/ is a
standalone module with zero cross-module dependencies; retracted rather
than adding an import to ../utils
- AbstractUIService → broadcast-channel/index value-import via barrel:
empirically amplifies circular dependencies 62 → 71 because the barrel also
re-exports ChargingStationWorkerBroadcastChannel whose transitive graph
closes a runtime cycle back through ui-server/index; deep import kept
Quality gates:
- pnpm typecheck: clean (root + ui-common + cli + web)
- pnpm lint: 0 errors (1 pre-existing cspell warning unchanged)
- pnpm circular-deps: 62 (baseline unchanged)
- pnpm test: 2955/2961 pass (6 skipped pre-existing; 0 fail)
* [autofix.ci] apply automated fixes
* refactor: address self-review findings — DRY constant refs, helper adoption, naming
Fixes 9 findings surfaced by fan-out self-review of commit
a25f4ba9.
Behavior-preserving; no public API change.
Findings addressed:
- H1 (DRY, Constants.ts) — `DEFAULT_AUTH_CACHE_MAX_ABSOLUTE_LIFETIME_MS` and
`MS_PER_DAY` both held the literal `86_400_000` (same value, same commit).
Class-static forward-reference is illegal in TypeScript (TS2729:
"Property 'B' is used before its initialization"), so the shared literal is
hoisted to module-scope `DAY_IN_MS` / `DAY_IN_SECONDS` helpers before the
class and referenced from both derived defaults and canonical time-unit
constants. Single source of truth restored.
- H2 (DRY, ConfigValidator.ts) — `AUTH_CACHE_LIFETIME_MAX_SECONDS = 86_400`
duplicated `Constants.SECONDS_PER_DAY = 86_400`. Fixed cross-file by
importing `Constants` (already reachable via `../../../../utils/index.js`)
and referencing `Constants.SECONDS_PER_DAY`.
- M1 (helper adoption, ui/cli format.ts:102) — `fuzzyTime` still used
`diff / 1000` while the same PR adopted `millisecondsToSeconds` from
`date-fns` at 4 other sites. Replaced by `millisecondsToSeconds(diff)`
for consistency.
- L1 (naming coherence, ConfigValidator.ts) — Renamed
`AUTH_CACHE_LIFETIME_{MIN,MAX}_SECONDS` to
`AUTH_CACHE_TTL_{MIN,MAX}_SECONDS` to align with the pre-existing
`DEFAULT_AUTH_CACHE_TTL_SECONDS` naming family. Two words for one concept
eliminated within the auth module.
- L2 (naming coherence, ui/cli output) — Renamed cli
`MISSING_VALUE_PLACEHOLDER` to `EMPTY_VALUE_PLACEHOLDER` to align with
ui/web's pre-existing `EMPTY_VALUE_PLACEHOLDER`. Values remain
medium-specific (`'–'` cli / `'Ø'` web), only the semantic role name is
shared. 6 sites updated.
- L3 (stranded export, format.ts) — `TRUNCATED_HASH_ID_LENGTH` had zero
external consumers (only the in-file default arg on `truncateId`).
Dropped `export` keyword.
- L4 (stranded export, format.ts) — `TEMPLATE_NAME_SUFFIX` had zero external
consumers (only the in-file `stripTemplateSuffix` helper). Dropped
`export` keyword; the helper itself keeps `export` (2 external consumers).
- L5 (documented duplication, WorkerUtils.ts) — JSDoc on `JITTER_PERCENT`
now explicitly cross-references `Constants.DEFAULT_RECONNECT_JITTER_PERCENT`
and documents the maintenance invariant. The two constants must stay
synchronized manually because `src/worker/` is a standalone module (no
cross-module value imports).
- I1 (anchor accuracy, StatisticUtils.ts:52) — The
`no-unnecessary-condition` disable reason is reworded from a plausible-
but-imprecise "JSON.parse violation" framing to the actual root cause:
`baseIndex+1` can equal `sortedDataSet.length`, and TypeScript indexes
as `number` (no `noUncheckedIndexedAccess`), so the runtime `!= null`
guard is genuinely needed for out-of-bounds protection.
Empirical retraction verifications:
- B1-5 (cycle amplification 62 → 71) — Re-verified by applying only the
retracted change on top of `
daad8a9d` and running `pnpm circular-deps`:
62 baseline, 71 after B1-5, 62 after revert. Retraction rationale holds.
- A1-17 (`src/worker/` standalone) — Re-verified by `grep -rE
"^import.*\.\./" src/worker/`: zero cross-module value imports; the new
L5 JSDoc documents the exception rationale in-source.
Quality gates:
- pnpm typecheck: clean (root + ui-common + cli + web)
- pnpm lint: 0 errors (1 pre-existing cspell warning unchanged)
- pnpm circular-deps: 62 (baseline unchanged)
- pnpm test: 2955/2961 pass (6 skipped pre-existing; 0 fail)
* docs: fix JSDoc accuracy from review v3 findings (G1, A1, A2)
Doc-only follow-up to commit
6ba8503c. Behavior unchanged.
- G1 (JITTER doc accuracy) — Reworded both `Constants.DEFAULT_RECONNECT_JITTER_PERCENT`
and worker-local `JITTER_PERCENT` JSDoc from the empirically false claim
"symmetric ±20 %" to "bounds the jitter within ±20 %". The `randomizeDelay`
algorithm couples sign and magnitude through a single uniform draw, so with
`random ∈ [0, 1)` and `sign = random < 0.5 ? -1 : +1` the output range is
`(0.9·delay, delay] ∪ [1.1·delay, 1.2·delay)` — asymmetric, with a gap at
`[delay, 1.1·delay)`. The new wording accurately caps the magnitude without
overclaiming symmetry; the WorkerUtils docstring adds an explicit distribution
note pointing to `randomizeDelay`. Algorithm itself pre-dates the PR and is
behavior-preserved.
- A1 (InMemoryAuthCache @param defaults) — Three JSDoc `@param default:` fields
were pointing at raw literals (3600,
86400000, 60000) instead of the promoted
Constants members. Aligned with the pattern already used for `maxEntries`
(which cites `Constants.DEFAULT_AUTH_CACHE_MAX_ENTRIES`): now all six
`@param default:` values reference the canonical `Constants.*` symbol.
- A2 (DEFAULT_REMOTE_AUTH_CACHE_TTL_SECONDS wording) — JSDoc used the phrase
"distinct from local `_TTL_SECONDS = 3600`" where `_TTL_SECONDS` is not an
actual symbol. Replaced by explicit sibling reference
`DEFAULT_AUTH_CACHE_TTL_SECONDS (3600, local cache default)`.
Quality gates:
- pnpm typecheck: clean (root + ui-common + cli + web)
- pnpm lint: 0 errors (1 pre-existing cspell warning unchanged)
- pnpm circular-deps: 62 (baseline unchanged)
* fix: web-test regression from A2-5 + review v4 findings + documented quality gates
Restores web test suite to full green after documented quality gates surfaced
regressions the prior review rounds missed. Behavior-preserving vs the intent
of round-1 refactor.
Regressions fixed (QG-1/QG-2/QG-3 — blockers surfaced by `pnpm --filter web
test:coverage`):
- `stationStatus.ts` — round-1 finding A2-5 replaced `switch (status?.toLowerCase())`
by a Record keyed by `OCPP16ChargePointStatus.*` / `OCPP20ConnectorStatusEnumType.*`
enum values. That change (a) dropped the case-insensitive contract asserted by
`stationStatus.test.ts:74`, and (b) introduced a module-scope runtime access
to `ui-common` enum values, which fails at module init when the containing
test suite mocks `ui-common` (2 file-level FAILs in `useAddStationsForm.test.ts`
and `useStartTxForm.test.ts`).
Design chosen (Option F in review v4 report): keep the Record structure but
key it by lowercase string literals and normalize input via `.toLowerCase()`
on lookup. Retains the refactor benefit (Record > switch for O(1) lookup,
clearer add/remove semantics), restores case-insensitive input, removes the
module-scope enum dependency, and preserves behaviour for the 9 status values
the switch covered. Enum imports dropped since no other value site remains.
Web test suite: 31 files / 532 tests pass (previously 3 files failed / 1
test failed).
Review v4 low-severity findings:
- v4-A1 `Constants.ts:77` — JSDoc "bounds within ±20 %" for
`DEFAULT_RECONNECT_JITTER_PERCENT` implied a bidirectional range, but algebra
of the sole consumer `computeExponentialBackOffDelay` (`jitter = delay ×
jitterPercent × secureRandom()` with `secureRandom() ∈ [0, 1)`) yields
`jitter ∈ [0, +20 %)` strictly non-negative. Reworded to describe the
additive-uniform-draw semantic and cross-reference the two distinct consumer
distributions.
- v4-A2 `WorkerUtils.ts:12` — "shared jitter policy" overclaimed behavioural
parity between the two consumers (positive-only vs asymmetric with a
probability-zero gap). Softened to "shared jitter magnitude cap".
- v4-B1 `ConfigurationFixtures.ts:67` — `uiServer.options` still hardcoded
`host: 'localhost', port: 8080` after the sibling `supervisionUrls` field on
the same object literal was migrated to `TEST_SUPERVISION_URL`. Extended
`tests/utils/TestNetworkConstants.ts` to also export `TEST_UI_SERVER_HOST`
and `TEST_UI_SERVER_PORT` (re-exports of `Constants.DEFAULT_UI_SERVER_HOST` /
`Constants.DEFAULT_UI_SERVER_PORT`).
- v4-B4 `ConfigurationMigrations.test.ts:130,141` — string literals
`'ws://localhost:8080'` in a file the PR already touched (barrel-to-source
import migration) but where the URL literals were skipped in round 2.
Migrated to `TEST_SUPERVISION_URL`.
Lint policy alignment (user directive "aucun warning"):
- `cspell.config.yaml` — added `subclassing` (used in
`src/charging-station/ui-server/index.ts:10`, pre-existing since #1954). Root
lint now reports 0 errors AND 0 warnings.
Deferred (documented in review v4 report, out of this PR's scope):
- v4-B2 `ConfigValidator.ts` MIN/MAX bounds harmonization (already tracked as M2)
- v4-B3 asymmetry between `1.6/__testable__/index.ts` (exports
`TestableOCPP16IncomingRequestService`) and `2.0/__testable__/index.ts`
(interface demoted to non-exported in round 2). Requires either restoring
the 2.0 export or migrating 1.6 consumers to `ReturnType<typeof …>` —
larger touch, kept for a dedicated follow-up.
- Pre-existing gap/asymmetry in `randomizeDelay`'s distribution
(`(−10 %, 0] ∪ [+10 %, +20 %)` — magnitude and sign both derive from a single
uniform draw). Documented in-source, not fixed by this refactor.
Quality gates (documented per `.serena/memories/task_completion_checklist`):
- Root: `pnpm format` / `typecheck` / `lint` (0 err, 0 warn) / `build` / `test`
(2955 pass / 6 skipped / 0 fail)
- CLI: `pnpm format` / `typecheck` / `lint` / `build` / `test` (152/152 pass)
- Web: `pnpm format` / `typecheck` / `lint` / `build` / `test:coverage`
(532/532 pass, 31 test files)
- Circular deps: 62 (baseline unchanged)
* docs: tighten prose in 5 comments — state-describing, not narrative
Removes historical/imperative framing ("kept as", "hoisted to", "if a future
edit converts...", "must not access... because") in favour of concise
statements of what is.
- `Constants.ts:9` module-scope helper: 3→2 lines
- `Constants.ts:80` DEFAULT_RECONNECT_JITTER_PERCENT JSDoc: 6→4 lines
- `WorkerUtils.ts:1` JITTER_PERCENT JSDoc: 9→5 lines
- `UIServiceWorkerBroadcastChannel.ts:1` type-only barrier: 5→3 lines
- `stationStatus.ts:64` CONNECTOR_STATUS_VARIANT: reworded, same length
Net: -9 lines. All invariants preserved (TS2729, cross-module boundary,
runtime-cycle prevention, no-module-scope-enum). Behavior unchanged.
Quality gates:
- Root: format / typecheck / lint (0 err, 0 warn) / build / test (2955 pass)
- CLI: 5/5 clean (152/152 pass)
- Web: 5/5 clean (532/532 pass, test:coverage)
* fix: address review v5 findings — imperative comment, dead exports, module-scope enum
Applies the 3 actionable findings from review round 5. Behavior-preserving.
- v5-A1 `SkinLoadError.vue:28-32` — Reworded `resetToDefault` JSDoc from 4-line
imperative `NOTE: ...should clear the reload-count sessionStorage key...`
to 1-line state-describing `Counter is reset by useSkin.switchSkin on
successful load.` Missed by the round-4 comment tightening sweep.
- v5-B1 `TestNetworkConstants.ts` / `ConfigurationFixtures.ts` — Retired
`TEST_UI_SERVER_HOST` / `TEST_UI_SERVER_PORT` exports (1 external consumer
each, both at the same `ConfigurationFixtures.ts:71` expression — criterion 10
requires ≥2 consumers). Inlined `Constants.DEFAULT_UI_SERVER_HOST` /
`Constants.DEFAULT_UI_SERVER_PORT` at the single call site.
`TEST_SUPERVISION_URL` remains exported (5 consumers).
- v5-B2 `ui/web/src/shared/utils/stationStatus.ts` — Removed the module-scope
value import `import { WebSocketReadyState } from 'ui-common'` and switched
`getWebSocketStateVariant` to 4 module-local numeric constants
`WS_STATE_{CONNECTING,OPEN,CLOSING,CLOSED} = 0/1/2/3`. Eliminates the same
ui-common runtime module-scope dependency that the round-4 fix removed for
`CONNECTOR_STATUS_VARIANT`. No behavior change (values match WHATWG WebSocket
readyState spec).
Quality gates:
- Root: 5/5 clean, 2942/2948 pass
- CLI: 5/5 clean, 152/152 pass
- Web: 5/5 clean, 532/532 pass (test:coverage)
- Circular deps: 62 (baseline)
* docs: drop narrative comments in stationStatus.ts
Removes two comment blocks that narrated rationale ("mirror of...",
"module-local to keep...", "test suites mock...") instead of describing
what is. The `WS_STATE_*` constants and lowercase Record keys are
self-documenting.
- `stationStatus.ts:62-66` — 4-line rationale block removed; `cspell:ignore`
directive retained
- `stationStatus.ts:83-85` — 3-line rationale block removed
Quality gates: root/web 5/5 clean, 532/532 pass.
---------
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
- entrancy
- recurrency
- shutdowning
+ - subclassing
- VCAP
- workerd
- yxxx
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)
sleep(
computeExponentialBackOffDelay({
baseDelayMs: Constants.DEFAULT_EXPONENTIAL_BACKOFF_BASE_DELAY_MS,
- jitterPercent: 0.2,
+ jitterPercent: Constants.DEFAULT_RECONNECT_JITTER_PERCENT,
retryNumber: messageIdx ?? 0,
})
)
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,
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
validated: Record<string, unknown>,
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`
)
-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,
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'
)
return response
} catch (error) {
- logger.error(
- `${chargingStation.logPrefix()} ${moduleName}.requestHandler: Error processing '${commandName}' request:`,
- error
- )
+ this.logRequestHandlerError(chargingStation, moduleName, commandName, error)
throw error
}
}
} from '../../../types/index.js'
import {
clampToSafeTimerValue,
+ Constants,
convertToBoolean,
convertToDate,
convertToInt,
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,
`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(
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'
this.chargingStation,
OCPP20ComponentName.SecurityCtrlr,
OCPP20OptionalVariableName.CertSigningWaitMinimum,
- 60
+ OCPP20Constants.DEFAULT_CERT_SIGNING_WAIT_MINIMUM_SECONDS
)
)
const delayMs = computeExponentialBackOffDelay({
// { 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
)
return response
} catch (error) {
- logger.error(
- `${chargingStation.logPrefix()} ${moduleName}.requestHandler: Error processing '${commandName}' request:`,
- error
- )
+ this.logRequestHandlerError(chargingStation, moduleName, commandName, error)
throw error
}
}
createPayloadConfigs,
PayloadValidatorOptions,
} from '../OCPPServiceUtils.js'
+import { OCPP20Constants } from './OCPP20Constants.js'
import { mapStopReasonToOCPP20 } from './OCPP20RequestBuilders.js'
import { OCPP20VariableManager } from './OCPP20VariableManager.js'
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),
* 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.
export {
createTestableRequestService,
- type SendMessageFn,
type SendMessageMock,
type TestableOCPP20RequestService,
- type TestableRequestServiceOptions,
- type TestableRequestServiceResult,
} from './OCPP20RequestServiceTestable.js'
export {
type TestableOCPP20ResponseService,
} from './OCPP20ResponseServiceTestable.js'
-export {
- createTestableVariableManager,
- type TestableOCPP20VariableManager,
-} from './OCPP20VariableManagerTestable.js'
+export { createTestableVariableManager } from './OCPP20VariableManagerTestable.js'
}
}
+ 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,
const moduleName = 'OCPPServiceUtils'
-const SOC_MAXIMUM_VALUE = 100
-
const isOCPP20FlagEnabled = (
chargingStation: ChargingStation,
component: OCPP20ComponentName,
return null
}
- const socMaximumValue = SOC_MAXIMUM_VALUE
+ const socMaximumValue = Constants.SOC_MAXIMUM_PERCENT
const socMinimumValue = socSampledValueTemplate.minimumValue ?? 0
const socSampledValueTemplateValue = isNotEmptyString(socSampledValueTemplate.value)
? getRandomFloatFluctuatedRounded(
connectorId,
convertToInt(socSampledValue.value),
socMeasurand.template.minimumValue ?? 0,
- SOC_MAXIMUM_VALUE,
+ Constants.SOC_MAXIMUM_PERCENT,
socSampledValue.measurand,
debug
)
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
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',
-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'
* - 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<string, CacheEntry>()
/**
* 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
}) {
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,
}
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)
export type {
AuthCache,
AuthComponentFactory,
- AuthStats,
AuthStrategy,
- CacheStats,
- CertificateAuthProvider,
- CertificateInfo,
DifferentialAuthEntry,
LocalAuthEntry,
LocalAuthListManager,
import type { AuthConfiguration, AuthorizationResult, AuthRequest } from '../types/AuthTypes.js'
import {
+ Constants,
ensureError,
getErrorMessage,
logger,
}
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)`
-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
)
}
- 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.`
)
)
}
- 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.`
)
)
}
- 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.`
)
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'
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(
'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,
import type { UIServerConfiguration } from '../../types/index.js'
-import { UI_SERVER_ACCESS_POLICY_DEFAULTS } from '../../utils/ConfigurationSchema.js'
import {
has,
isEmpty,
isNotEmptyArray,
normalizeHost,
normalizeIPAddress,
+ UI_SERVER_ACCESS_POLICY_DEFAULTS,
} from '../../utils/index.js'
import { splitHeaderList, splitQuoted } from './UIServerNet.js'
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:
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 = (
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,
},
},
})
export { registerMCPResources, registerMCPSchemaResources } from './MCPResources.js'
export { registerMCPLogTools } from './MCPTools.js'
export { mcpToolSchemas, ocppSchemaMapping } from './MCPToolSchemas.js'
-export type { MCPToolSchema } from './MCPToolSchemas.js'
const moduleName = 'PerformanceStatistics'
+const STATISTICS_PERCENTILE = 95
+
export class PerformanceStatistics {
private static readonly instances: Map<string, PerformanceStatistics> = new Map<
string,
entryStatisticsData.medTimeMeasurement = median(timeMeasurementValues)
entryStatisticsData.ninetyFiveThPercentileTimeMeasurement = percentile(
timeMeasurementValues,
- 95
+ STATISTICS_PERCENTILE
)
entryStatisticsData.stdTimeMeasurement = std(
timeMeasurementValues,
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<AutomaticTransactionGeneratorConfiguration> =
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
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<Partial<ChargingStationInfo>> = Object.freeze({
automaticTransactionGeneratorPersistentConfiguration: true,
autoReconnectMaxRetries: -1,
// 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
}
}
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] +
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)
)
type AsyncFunctionType<A extends unknown[], R> = (...args: A) => PromiseLike<R>
-// 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
/**
} 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 {
mergeDeepRight,
once,
promiseWithTimeout,
- queueMicrotaskErrorThrowing,
roundTo,
secureRandom,
sleep,
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<string, unknown> => {
if (typeof value !== 'object' || value === null) return false
return Object.prototype.toString.call(value).slice(8, -1) === 'Object'
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
}
+import { secondsToMilliseconds } from 'date-fns'
/**
* @file Tests for ChargingStation Configuration Management
* @description Unit tests for boot notification, config persistence, and WebSocket handling
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'
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)
})
// 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
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', () => {
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)
* buildSignedOCPP16SampledValue, and periodic meter values.
*/
+import { minutesToMilliseconds } from 'date-fns'
import assert from 'node:assert/strict'
import { afterEach, beforeEach, describe, it } from 'node:test'
// Act
OCPP16ServiceUtils.startUpdatedMeterValues(station, 1, 60)
- t.mock.timers.tick(60000)
+ t.mock.timers.tick(minutesToMilliseconds(1))
// Assert
assert.ok(capturedMeterValue != null)
// Act
OCPP16ServiceUtils.startUpdatedMeterValues(station, 1, 60)
- t.mock.timers.tick(60000)
+ t.mock.timers.tick(minutesToMilliseconds(1))
// Assert
assert.ok(capturedMeterValue != null)
* @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'
// Act
manager.startRetryTimer()
- t.mock.timers.tick(60000)
+ t.mock.timers.tick(minutesToMilliseconds(1))
await flushMicrotasks()
// Assert
)
manager.startRetryTimer()
- t.mock.timers.tick(60000)
+ t.mock.timers.tick(minutesToMilliseconds(1))
await flushMicrotasks()
assert.strictEqual(requestHandlerMock.mock.callCount(), 0)
// Act
manager.cancelRetryTimer()
- t.mock.timers.tick(60000)
+ t.mock.timers.tick(minutesToMilliseconds(1))
await flushMicrotasks()
// Assert
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)
})
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)
})
// 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)
})
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'
})
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)
})
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)
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
id,
name: name ?? `cs-${id}`,
statisticsData: statsData,
- uri: 'ws://localhost:8080',
+ uri: TEST_SUPERVISION_URL,
}
}
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(() => {
logEnabled: true,
logFile: '/logs/combined.log',
stationTemplateURLs: [{ file: 'a.json', numberOfStations: 1 }],
- supervisionURLs: 'ws://localhost:8080',
+ supervisionURLs: TEST_SUPERVISION_URL,
workerProcess: 'workerSet',
})
assert.strictEqual((result.log as Record<string, unknown>).enabled, true)
assert.strictEqual((result.log as Record<string, unknown>).file, '/logs/combined.log')
assert.strictEqual((result.worker as Record<string, unknown>).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)
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
// 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`),
})
})
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')
})
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', () => {
},
],
]),
- uri: 'ws://localhost:8080',
+ uri: TEST_SUPERVISION_URL,
}
const message = buildPerformanceStatisticsMessage(statistics)
},
],
]),
- uri: 'ws://localhost:8080',
+ uri: TEST_SUPERVISION_URL,
}
const message = buildPerformanceStatisticsMessage(statistics)
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)
})
})
--- /dev/null
+/**
+ * @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()}`
mergeDeepRight,
once,
promiseWithTimeout,
- queueMicrotaskErrorThrowing,
roundTo,
secureRandom,
sleep,
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 () => {
* @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.
{ 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',
},
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))
}
process.on('SIGINT', () => {
- cleanup(130)
+ cleanup(SIGINT_EXIT_CODE)
})
process.on('SIGTERM', () => {
- cleanup(143)
+ cleanup(SIGTERM_EXIT_CODE)
})
}
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: '',
'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({
...(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 =>
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<string, string> = {
- Finishing: 'Fi',
- SuspendedEV: 'SE',
- SuspendedEVSE: 'SS',
+ [OCPP16ChargePointStatus.FINISHING]: 'Fi',
+ [OCPP16ChargePointStatus.SUSPENDED_EV]: 'SE',
+ [OCPP16ChargePointStatus.SUSPENDED_EVSE]: 'SS',
}
const statusLetter = (status: string | undefined): string => {
}
}
- 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`)
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'
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 &&
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'),
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`
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 = {
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,
</template>
<script setup lang="ts">
-import { DEFAULT_SKIN, MAX_SKIN_ERROR_RELOADS, setToLocalStorage } from '@/core/index.js'
+import {
+ DEFAULT_SKIN,
+ MAX_SKIN_ERROR_RELOADS,
+ setToLocalStorage,
+ SKIN_ERROR_RELOAD_COUNT_KEY,
+} from '@/core/index.js'
import { SKIN_STORAGE_KEY } from '@/shared/composables/useSkin.js'
// Intentional: registry.ts is pure metadata (ids, labels, loaders) — no behavioral coupling.
import { skins } from '@/skins/registry.js'
const defaultSkinLabel = skins.find(s => s.id === DEFAULT_SKIN)?.label ?? 'Default'
-/**
- * Resets to default skin with reload loop protection.
- * NOTE: Successful skin loads (e.g. in useSkin.switchSkin) should clear
- * the 'skin-error-reload-count' sessionStorage key to reset the counter.
- */
+/** Resets to default skin with reload loop protection. Counter is reset by `useSkin.switchSkin` on successful load. */
function resetToDefault (): void {
- const RELOAD_KEY = 'skin-error-reload-count'
let count = 0
try {
- count = Number(sessionStorage.getItem(RELOAD_KEY) ?? '0')
+ count = Number(sessionStorage.getItem(SKIN_ERROR_RELOAD_COUNT_KEY) ?? '0')
} catch {
// sessionStorage unavailable (e.g. Safari private browsing)
}
return
}
try {
- sessionStorage.setItem(RELOAD_KEY, String(count + 1))
+ sessionStorage.setItem(SKIN_ERROR_RELOAD_COUNT_KEY, String(count + 1))
} catch {
// sessionStorage unavailable — proceed with reset anyway
}
import { useToast } from 'vue-toast-notification'
import { useTemplates, useUIClient } from '@/core/index.js'
+import { nonEmptyStringOrUndefined } from '@/shared/utils/index.js'
export interface AddStationsFormState {
autoStart: boolean
formState.value.numberOfStations,
{
autoStart: formState.value.autoStart,
- baseName: nonEmpty(formState.value.baseName),
+ baseName: nonEmptyStringOrUndefined(formState.value.baseName),
enableStatistics: formState.value.enableStatistics,
- fixedName: formState.value.baseName.length > 0 ? formState.value.fixedName : undefined,
+ fixedName:
+ nonEmptyStringOrUndefined(formState.value.baseName) != null
+ ? formState.value.fixedName
+ : undefined,
ocppStrictCompliance: formState.value.ocppStrictCompliance,
persistentConfiguration: formState.value.persistentConfiguration,
- supervisionPassword: nonEmpty(formState.value.supervisionPassword),
- supervisionUrls: nonEmpty(formState.value.supervisionUrl),
- supervisionUser: nonEmpty(formState.value.supervisionUser),
+ supervisionPassword: nonEmptyStringOrUndefined(formState.value.supervisionPassword),
+ supervisionUrls: nonEmptyStringOrUndefined(formState.value.supervisionUrl),
+ supervisionUser: nonEmptyStringOrUndefined(formState.value.supervisionUser),
}
)
$toast.success('Charging stations successfully added')
}
}
-/**
- * Returns `value` when it is non-empty, otherwise `undefined`.
- * @param value - The string to test.
- * @returns The original string, or `undefined` if it is empty.
- */
-function nonEmpty (value: string): string | undefined {
- return value.length > 0 ? value : undefined
-}
-
/**
* Returns `true` when both arrays have identical length and values in the same order.
* @param a - The incoming template list.
import { type SKIN_IDS } from 'ui-common'
import { readonly, ref, type Ref } from 'vue'
-import { DEFAULT_SKIN, getFromLocalStorage, setToLocalStorage } from '@/core/index.js'
+import {
+ DEFAULT_SKIN,
+ getFromLocalStorage,
+ setToLocalStorage,
+ SKIN_ERROR_RELOAD_COUNT_KEY,
+} from '@/core/index.js'
import { validateTokenContract } from '@/shared/tokens/contract.js'
// Intentional: registry.ts is pure metadata (ids, labels, loaders) — no behavioral coupling.
import { type SkinDefinition, skins } from '@/skins/registry.js'
try {
await loadSkinStyles(skinId)
try {
- sessionStorage.removeItem('skin-error-reload-count')
+ sessionStorage.removeItem(SKIN_ERROR_RELOAD_COUNT_KEY)
} catch {
/* sessionStorage unavailable */
}
}
setToLocalStorage<string>(SKIN_STORAGE_KEY, skinId)
try {
- sessionStorage.removeItem('skin-error-reload-count')
+ sessionStorage.removeItem(SKIN_ERROR_RELOAD_COUNT_KEY)
} catch {
/* sessionStorage unavailable */
}
import { useToast } from 'vue-toast-notification'
import { useUIClient } from '@/core/index.js'
+import { nonEmptyStringOrUndefined } from '@/shared/utils/index.js'
export interface StartTxFormConfig {
connectorId: string
if (pending.value) return false
pending.value = true
try {
- const idTag = formState.value.idTag.length > 0 ? formState.value.idTag : undefined
+ const idTag = nonEmptyStringOrUndefined(formState.value.idTag)
if (formState.value.authorizeIdTag) {
if (idTag == null) {
export { getSelectValue } from './dom.js'
export { formatSupervisionUrl } from './formatSupervisionUrl.js'
+export { nonEmptyStringOrUndefined } from './nonEmptyString.js'
export type { StatusVariant } from './stationStatus.js'
export {
getATGStatus,
--- /dev/null
+/**
+ * Returns `value` when it is a non-empty string, otherwise `undefined`.
+ * Accepts `unknown` so it can double as a type guard for object-property lookups.
+ * @param value - The candidate value.
+ * @returns The original string when non-empty, otherwise `undefined`.
+ */
+export const nonEmptyStringOrUndefined = (value: unknown): string | undefined =>
+ typeof value === 'string' && value.length > 0 ? value : undefined
}))
}
+// cspell:ignore suspendedev suspendedevse
+const CONNECTOR_STATUS_VARIANT: Readonly<Record<string, StatusVariant>> = Object.freeze({
+ available: 'ok',
+ charging: 'warn',
+ faulted: 'err',
+ finishing: 'warn',
+ occupied: 'warn',
+ preparing: 'warn',
+ suspendedev: 'warn',
+ suspendedevse: 'warn',
+ unavailable: 'err',
+})
+
/**
* Maps an OCPP connector status string to a display variant.
* @param status - The OCPP connector status value
* @returns The display variant for the status
*/
export function getConnectorStatusVariant (status?: string): StatusVariant {
- // cspell:ignore suspendedev suspendedevse
- switch (status?.toLowerCase()) {
- case 'available':
- return 'ok'
- // Active use states: amber to distinguish from 'available' (green)
- case 'charging':
- case 'occupied':
- return 'warn'
- case 'faulted':
- case 'unavailable':
- return 'err'
- case 'finishing':
- case 'preparing':
- case 'suspendedev':
- case 'suspendedevse':
- return 'warn'
- default:
- return 'idle'
- }
+ if (status == null) return 'idle'
+ return CONNECTOR_STATUS_VARIANT[status.toLowerCase()] ?? 'idle'
}
+const WS_STATE_CLOSED = 3
+const WS_STATE_CLOSING = 2
+const WS_STATE_CONNECTING = 0
+const WS_STATE_OPEN = 1
+
/**
* Maps a WebSocket ready state to a display variant.
* @param wsState - The WebSocket readyState value
*/
export function getWebSocketStateVariant (wsState?: number): StatusVariant {
switch (wsState) {
- case 0: // WebSocket.CONNECTING
+ case WS_STATE_CLOSED:
+ return 'err'
+ case WS_STATE_CLOSING:
return 'warn'
- case 1: // WebSocket.OPEN
- return 'ok'
- case 2: // WebSocket.CLOSING
+ case WS_STATE_CONNECTING:
return 'warn'
- case 3: // WebSocket.CLOSED
- return 'err'
+ case WS_STATE_OPEN:
+ return 'ok'
default:
return 'idle'
}
import { useSimulatorControl } from '@/shared/composables/useSimulatorControl.js'
import { useSkin } from '@/shared/composables/useSkin.js'
import { type ThemeName, useTheme } from '@/shared/composables/useTheme.js'
-import { getSelectValue } from '@/shared/utils/dom.js'
+import { getSelectValue } from '@/shared/utils/index.js'
import StateButton from './components/buttons/StateButton.vue'
import ToggleButton from './components/buttons/ToggleButton.vue'
ROUTE_NAMES,
} from '@/core/index.js'
import { useStationActions } from '@/shared/composables/useStationActions.js'
-import { formatSupervisionUrl } from '@/shared/utils/formatSupervisionUrl.js'
-import { getATGStatus, getConnectorEntries } from '@/shared/utils/stationStatus.js'
+import { formatSupervisionUrl, getATGStatus, getConnectorEntries } from '@/shared/utils/index.js'
import Button from '../buttons/ClassicButton.vue'
import StateButton from '../buttons/StateButton.vue'
import { WH_PER_KWH } from '@/core/index.js'
import { useConnectorActions } from '@/shared/composables/useConnectorActions.js'
-import { getConnectorStatusVariant } from '@/shared/utils/stationStatus.js'
+import { getConnectorStatusVariant } from '@/shared/utils/index.js'
import ActionButton from './ActionButton.vue'
import SetConnectorStatusDialog from './dialogs/SetConnectorStatusDialog.vue'
import { useSkin } from '@/shared/composables/useSkin.js'
import { type ThemeName, useTheme } from '@/shared/composables/useTheme.js'
-import { getSelectValue } from '@/shared/utils/dom.js'
+import { getSelectValue } from '@/shared/utils/index.js'
import ActionButton from './ActionButton.vue'
import StatePill from './StatePill.vue'
import { deleteLocalStorageByKeyPattern, EMPTY_VALUE_PLACEHOLDER as EMPTY } from '@/core/index.js'
import { useStationActions } from '@/shared/composables/useStationActions.js'
-import { formatSupervisionUrl } from '@/shared/utils/formatSupervisionUrl.js'
import {
+ formatSupervisionUrl,
getATGStatus,
getConnectorEntries,
getWebSocketStateVariant,
-} from '@/shared/utils/stationStatus.js'
+} from '@/shared/utils/index.js'
import ActionButton from './ActionButton.vue'
import ConfirmDialog from './ConfirmDialog.vue'
import { useChargingStations, useUIClient } from '@/core/index.js'
import { useSetUrlForm } from '@/shared/composables/useSetUrlForm.js'
-import { stripStationId } from '@/shared/utils/stripStationId.js'
+import { stripStationId } from '@/shared/utils/index.js'
import ActionButton from '../ActionButton.vue'
import Modal from '../ModernModal.vue'
import { extractErrorMessage, type ResponsePayload, ServerFailureError } from 'ui-common'
+import { nonEmptyStringOrUndefined } from '@/shared/utils/index.js'
+
export interface FailureInfo {
payload?: ResponsePayload
summary: string
const asRecord = (value: unknown): Record<string, unknown> | undefined =>
typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : undefined
-const stringField = (rec: Record<string, unknown> | undefined, key: string): string | undefined => {
- const v = rec?.[key]
- return typeof v === 'string' && v.length > 0 ? v : undefined
-}
+const stringField = (rec: Record<string, unknown> | undefined, key: string): string | undefined =>
+ nonEmptyStringOrUndefined(rec?.[key])
export const getFailureInfo = (error: unknown): FailureInfo => {
if (error instanceof ServerFailureError) {