refactor(ocpp): extract per-station state plumbing into shared OCPPIncomingRequestService base (#1983)
Closes #1963. Companion issues #1971, #1972, #1973 will consume this base.
Pure structural refactor + OCPP 1.6 concretization. Zero observable
behavior change on OCPP 2.0.1: every field currently cleared in the
former OCPP20IncomingRequestService.stop() body is still cleared, in
the same order, at the same lifecycle point.
Touchpoints:
A. src/charging-station/ocpp/OCPPIncomingRequestService.ts
Base becomes generic <TStationState extends object = object>. Adds
shared 'stationsState' WeakMap (L86), 'getOrCreateStationState'
lazy-init, concrete 'stop()' template (bound in the constructor at
L94 to prevent detach-and-call regressions), and abstract hooks
'createStationState' / 'resetStationState'. Documents the
exception-safety contract on the template: a throw in
'resetStationState' skips WeakMap eviction and any subclass
extension after 'super.stop()' — matches pre-refactor semantics.
The '= object' default on the generic parameter is load-bearing
(removing it would break the static registry Map and the
getInstance constraint with TS2314).
B. src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.ts
Extends OCPPIncomingRequestService<OCPP20StationState>. Removes
local 'stationsState' field and 'getOrCreateStationState'. Adds
'createStationState' factory and 'resetStationState' override with
the 5-statement ordering invariant of the pre-refactor stop() body.
The abort-before-clear ordering matters because clearing first
makes the subsequent '?.abort()' short-circuit on the nulled field,
leaving the in-flight operation un-signaled. 'stop()' override
calls super first, then keeps the unconditional
OCPP20VariableManager cleanup. Class-level JSDoc documents OCPP 2.1
subclass path (extends OCPP20IncomingRequestService inherits state,
reset, and stop() unchanged; widening the generic parameter for new
fields requires a preparatory refactor with a factory cast per
TS2352, or subclass override of createStationState).
C. src/charging-station/ocpp/1.6/OCPP16IncomingRequestService.ts
Adds empty 'OCPP16StationState' interface. Extends
OCPPIncomingRequestService<OCPP16StationState>. Adds no-op
'createStationState' + 'resetStationState' overrides. Removes the
'/* no-op for OCPP 1.6 */' stop() override (now inherited from the
base template). The 'resetStationState' JSDoc prescribes the
abort-before-clear ordering companion issues MUST follow.
D. eslint.config.js
Adds 'no-restricted-syntax' rule enforcing the INVARIANT: forbids
direct '.set/.delete/.clear' calls on 'stationsState' from outside
the base class file, forbids aliasing 'stationsState' to a local
binding, and forbids destructuring 'stationsState'. Covers the AST
escape hatches identified during hostile-adversarial review.
Enforced by 'pnpm lint' in CI on every companion PR.
E. src/charging-station/ocpp/OCPPServiceUtils.ts
Adds bidirectional cross-reference in the comment on
'warnedInvalidMeasurands' distinguishing the module-scope warn-once
diagnostic cache from the class-scope lifecycle-state WeakMap on
OCPPIncomingRequestService.stationsState.
Companion issues consume this base infrastructure in order:
- #1971 GetDiagnostics supersession -> activeDiagnosticsAbortController + Id
- #1972 firmware setTimeout cancel -> deferred firmware timer handle
- #1973 trigger cross-check -> activeFirmwareUpdateRequestId
#1971 and #1973 share 'activeDiagnosticsRequestId': whichever lands
first adds the (optional) field to OCPP16StationState; the second
lands as-is.
Line-count delta (plumbing moved, not duplicated):
Base : 182 -> 290 (+108) plumbing + docs (~+80 JSDoc / +28 code)
OCPP20 : 4579 -> 4627 (+48) 22 lines of plumbing removed; docs +55,
net code -7 (concrete overrides only)
OCPP16 : 1958 -> 1985 (+27) interface + 2 concrete implementations
+ companion guidance
eslint : 172 -> 198 (+26) 3-selector no-restricted-syntax rule
utils : 2000 -> 2004 (+4) bidirectional cross-ref comment
test : new file (+150) 7 plumbing tests
Origin of the OCPP 2.0.1 pattern being harmonized:
-
47cdf2bbe refactor(ocpp20): isolate per-station state with
WeakMap instead of singleton properties (introduces the WeakMap
pattern with initial names 'OCPP20PerStationState' / 'stationStates'
/ 'getStationState')
-
f1e33ea42 refactor(ocpp20): harmonize per-station state naming
(renames to 'OCPP20StationState' / 'stationsState')
-
17396c1c4 refactor(ocpp20): rename getStationState to
getOrCreateStationState (lazy-getter naming split)
-
c0b25553 fix(ocpp20): cancel cert-signing retry timer in stop()
-
be29b4c8 fix(ocpp20): add AbortController to log-upload lifecycle
+ stop() abort
-
d2e9b8b0 refactor(ocpp20): extract resetActiveFirmwareUpdateState
helper
-
ce875dae refactor(ocpp20): harmonize firmware-state cleanup + log
superseded
Tests: 7 new plumbing tests in
tests/charging-station/ocpp/OCPPIncomingRequestService-StationState.test.ts
covering lazy-init idempotency (with createStationState spy verifying
call count = 1 after two getOrCreate calls), WeakMap eviction on
stop(), resetStationState invocation count, no-op guard when no state
exists, two-station isolation, the OCPP 1.6 default resetStationState
no-op (with reference identity check), and the exception-safety
contract lock (throw in resetStationState skips WeakMap.delete, and
subsequent stop() re-invokes on same state before evicting). Zero
edits to existing OCPP 2.0.1 tests.
The OCPP 2.0.1 5-statement ordering invariant is enforced by four
independent mechanisms: (1) JSDoc rationale on
OCPP20IncomingRequestService.resetStationState documenting the
'?.abort()' short-circuit consequence of reordering, (2) the
'no-restricted-syntax' ESLint rule preventing direct 'stationsState'
mutations from subclass code (with selector coverage for aliasing and
destructuring bypasses; enforced in CI), (3) constructor
'stop.bind(this)' defense-in-depth against detach-and-call
regressions, and (4) byte-identical preservation of the pre-refactor
stop() body.