Skip to content

refactor(ocpp): extract per-station state plumbing into shared OCPPIncomingRequestService base#1983

Merged
jerome-benoit merged 1 commit into
mainfrom
refactor/ocpp-shared-per-station-state
Jul 8, 2026
Merged

refactor(ocpp): extract per-station state plumbing into shared OCPPIncomingRequestService base#1983
jerome-benoit merged 1 commit into
mainfrom
refactor/ocpp-shared-per-station-state

Conversation

@jerome-benoit

@jerome-benoit jerome-benoit commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

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.

Design goal

Maximize code sharing between the OCPP 1.6 and OCPP 2.0.1 stacks. The WeakMap<ChargingStation, TStationState> + lazy-init getter + stop() template lives ONCE in OCPPIncomingRequestService, parameterized over the concrete station-state type. Version-specific state interfaces (OCPP16StationState, OCPP20StationState) stay distinct.

Touchpoints

# File Change
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. The stop() template documents an exception-safety contract: if resetStationState throws, WeakMap eviction is skipped and a subsequent stop() re-invokes the hook on the same state. The = object default on the generic parameter is load-bearing (removing it breaks the static instances registry and the getInstance constraint with TS2314 — see the base's JSDoc for details).
B src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.ts extends OCPPIncomingRequestService<OCPP20StationState>. Removes local stationsState field and local getOrCreateStationState. Adds createStationState factory and resetStationState override with the 5-statement ordering invariant preserved bit-for-bit from the pre-refactor stop() body. The abort-before-clear ordering matters because clearing first would make the subsequent ?.abort() short-circuit on the nulled field, leaving the in-flight operation un-signaled. stop() override calls super.stop() first, then keeps the unconditional OCPP20VariableManager cleanup. Class-level JSDoc documents the OCPP 2.1 subclass path (see the file's JSDoc for cast requirements).
C src/charging-station/ocpp/1.6/OCPP16IncomingRequestService.ts Adds intentionally empty OCPP16StationState interface — companion issues populate. 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 a no-restricted-syntax rule enforcing the stationsState INVARIANT. Three selectors cover direct mutation (.set / .delete / .clear), aliasing (const x = something.stationsState), and destructuring (const { stationsState } = something). Enforced by pnpm lint in CI on every companion PR — attempting to bypass will fail the pipeline.
E src/charging-station/ocpp/OCPPServiceUtils.ts Bidirectional cross-reference in the comment on warnedInvalidMeasurands (a sibling module-scope diagnostic WeakMap<ChargingStation, Set<string>>) distinguishing it from the class-scope lifecycle-state WeakMap on OCPPIncomingRequestService.stationsState — no stop() lifecycle, no abort semantics, no resetStationState hook.

Companion issues (tracking issues, not open PRs) consuming this base

Coordination note: #1971 and #1973 both touch activeDiagnosticsRequestId. Convention: whichever of the two lands first adds the field to OCPP16StationState (marked optional ?: number); the second lands as-is and relies on the field being present. No merge-order dependency for the interface itself, but if #1973 lands first, its author declares the field defensively.

Each companion PR-to-come only adds fields to OCPP16StationState and populates the currently-no-op resetStationState — zero plumbing duplication. Verified empirically: none of the three companion patterns trip the no-restricted-syntax rule.

Line-count delta (plumbing moved, not duplicated)

File Before After Δ JSDoc / Code split
OCPPIncomingRequestService.ts (base) 182 290 +108 ≈ +80 JSDoc / +28 code (WeakMap field + stop.bind + stop template body + getOrCreateStationState + 2 abstract signatures)
OCPP20IncomingRequestService.ts 4579 4627 +48 ≈ +55 JSDoc / −7 code net (22 lines of plumbing removed; ~15 lines of concrete-override method bodies added; balance is documentation)
OCPP16IncomingRequestService.ts 1958 1985 +27 ≈ +18 JSDoc / +9 code (interface decl + 2 concrete-override bodies + generic class-declaration change)
eslint.config.js 172 198 +26 3-selector no-restricted-syntax rule
OCPPServiceUtils.ts 2000 2004 +4 Bidirectional cross-reference comment on warnedInvalidMeasurands
tests/…/OCPPIncomingRequestService-StationState.test.ts 150 new 7 targeted plumbing tests

Base and OCPP20 additions are documentation-heavy: the code footprint is smaller than the raw delta suggests. Plumbing is moved from OCPP20 to the base, not duplicated.

Behavior-preservation audit (bit-for-bit)

Pre-refactor OCPP20IncomingRequestService.stop() executed:

  1. activeFirmwareUpdateAbortController?.abort()
  2. activeLogUploadAbortController?.abort()
  3. certSigningRetryManager?.cancelRetryTimer()
  4. resetActiveFirmwareUpdateState(state)
  5. resetActiveLogUploadState(state)
  6. stationsState.delete(chargingStation) — inside the if (stationState != null) guard
  7. (outside guard, unconditional) variableManager.resetRuntimeOverrides(...) + invalidateMappingsCache(...) in try/catch

Post-refactor: steps 1–5 live in OCPP20IncomingRequestService.resetStationState (invoked exactly once per stop() and only when a state entry exists). Step 6 is the base template's stationsState.delete (invoked immediately after resetStationState returns, still inside the same guard). Step 7 remains in the OCPP20IncomingRequestService.stop() override, after super.stop(), still unconditional. Same order, same guards, same exception-propagation semantics.

Origin of the OCPP 2.0.1 pattern being harmonized

  • 47cdf2bberefactor(ocpp20): isolate per-station state with WeakMap instead of singleton properties (introduces the WeakMap pattern).
  • f1e33ea42refactor(ocpp20): harmonize per-station state naming (renames to OCPP20StationState / stationsState).
  • 17396c1c4refactor(ocpp20): rename getStationState to getOrCreateStationState (lazy-getter naming split).
  • c0b25553fix(ocpp20): cancel cert-signing retry timer in stop().
  • be29b4c8fix(ocpp20): add AbortController to log-upload lifecycle + stop() abort.
  • d2e9b8b0refactor(ocpp20): extract resetActiveFirmwareUpdateState helper.
  • ce875daerefactor(ocpp20): harmonize firmware-state cleanup + log superseded.

Tests

New file: tests/charging-station/ocpp/OCPPIncomingRequestService-StationState.test.ts7 targeted plumbing tests:

  1. Lazy-init idempotency — with mock.method spy on createStationState, verifies callCount === 1 after two getOrCreateStationState calls (proves laziness, not just WeakMap memoization).
  2. stop() deletes the WeakMap entry.
  3. stop() calls resetStationState exactly once with the correct state reference.
  4. stop() is a no-op when the state was never created.
  5. Two-station isolation — stop(A) does not affect B.
  6. OCPP 1.6 default resetStationState performs no observable mutation + reference identity is preserved after the call.
  7. Exception-safety contract lock — throw in resetStationState skips WeakMap.delete, and a subsequent stop() re-invokes the hook on the same state before evicting.

Follows tests/TEST_STYLE_GUIDE.md: afterEach(() => { standardCleanup() }) for canonical mock restoration via mock.restoreAll().

Zero edits to existing tests. Full suite: 0 fail, 7 new passing tests, all pre-existing OCPP 2.0.1 stop/supersession/trigger tests green.

Verification gates

  • pnpm format clean
  • pnpm typecheck exit 0
  • pnpm lint 0 errors (expanded no-restricted-syntax rule doesn't false-positive on any existing code — empirically confirms the invariant is upheld codebase-wide; also enforced in CI on companion PRs)
  • pnpm test 0 fail (2992+ pass; +7 new tests all passing)
  • pnpm build exit 0

Manual grep sanity

  • stationsState = new WeakMapexactly 1 declaration in the codebase (base class only).
  • getOrCreateStationStateexactly 1 definition (base class).
  • createStationState2 concretes + 1 abstract.
  • resetStationState2 concretes + 1 abstract + 1 call site (from the base stop() template).

Enforcement of the OCPP 2.0.1 5-statement ordering invariant

Four independent mechanisms enforce the abort-before-clear ordering:

  1. JSDoc rationale on OCPP20IncomingRequestService.resetStationState documenting the causal explanation: clearing first would make the subsequent ?.abort() short-circuit on the nulled field, leaving the in-flight operation un-signaled.
  2. no-restricted-syntax ESLint rule with 3 selectors preventing direct stationsState mutations, aliasing, and destructuring from subclass code (enforced by pnpm lint in CI).
  3. Constructor stop.bind(this) defense-in-depth against detach-and-call regressions, symmetric with the pre-existing bind policy on incomingRequestHandler and validateIncomingRequestPayload.
  4. Byte-identical preservation of the pre-refactor stop() body — verified callsite-by-callsite against git show HEAD~1.

@hyperspace-insights

Copy link
Copy Markdown
Contributor

Summary

The following content is AI-generated and provides a summary of the pull request:


refactor(ocpp): Extract per-station state plumbing into shared OCPPIncomingRequestService base

Refactor

This PR lifts the WeakMap<ChargingStation, TStationState> + lazy-init getter + stop() template pattern from OCPP20IncomingRequestService into the shared OCPPIncomingRequestService base class, making it available to both OCPP 1.6 and 2.0.1 stacks with zero code duplication.

Changes

OCPPIncomingRequestService.ts (base)

  • Made generic: OCPPIncomingRequestService<TStationState extends object = object>
  • Added the single canonical stationsState: WeakMap<ChargingStation, TStationState> declaration
  • Added concrete stop() template (reset-then-delete ordering invariant)
  • Added getOrCreateStationState() lazy-init getter
  • Added abstract hooks: createStationState() and resetStationState()

OCPP20IncomingRequestService.ts

  • Now extends OCPPIncomingRequestService<OCPP20StationState>
  • Removed local stationsState WeakMap and local getOrCreateStationState()
  • Added createStationState() factory and resetStationState() override preserving the exact pre-refactor abort/cancel ordering: activeFirmwareUpdateAbortController?.abort()activeLogUploadAbortController?.abort()certSigningRetryManager?.cancelRetryTimer() → reset helpers
  • stop() override calls super.stop() first, then handles unconditional OCPP20VariableManager cleanup

OCPP16IncomingRequestService.ts

New test file: OCPPIncomingRequestService-StationState.test.ts

  • 6 targeted plumbing tests: lazy-init idempotency, stop() deletes entry, resetStationState called exactly once, no-op on missing state, two-station isolation, OCPP 1.6 default resetStationState performs no mutation

Impact

Closes #1963


  • 🔄 Regenerate and Update Summary
  • ✏️ Insert as PR Description (deletes this comment)
  • 🗑️ Delete comment
PR Bot Information

Version: 1.27.2

@hyperspace-insights hyperspace-insights Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR is a well-structured refactor that correctly extracts the per-station state plumbing into the base class. Three substantive issues were found: (1) the stop() template method is missing a try/finally guard, meaning a throwing resetStationState implementation would leave a stale WeakMap entry; (2) the two mock.method spy patches in the new test file lack a TestContext argument (t), so they are never automatically restored by the Node.js test runner; and (3) the test file is missing the mandatory afterEach(() => { standardCleanup() }) call required by the project's TEST_STYLE_GUIDE, risking singleton state leakage across tests. There is also a minor documentation inaccuracy where the base-class JSDoc describes stationsState as "private" while it is declared protected.

PR Bot Information

Version: 1.27.2

Comment thread src/charging-station/ocpp/OCPPIncomingRequestService.ts Outdated
Comment thread src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.ts
Comment thread src/charging-station/ocpp/OCPPIncomingRequestService.ts
Comment thread src/charging-station/ocpp/OCPPIncomingRequestService.ts Outdated
Comment thread src/charging-station/ocpp/OCPPIncomingRequestService.ts
Comment thread src/charging-station/ocpp/OCPPIncomingRequestService.ts
Comment thread src/charging-station/ocpp/1.6/OCPP16IncomingRequestService.ts Outdated
@jerome-benoit jerome-benoit force-pushed the refactor/ocpp-shared-per-station-state branch 6 times, most recently from bd6c1bb to f796062 Compare July 8, 2026 19:23
…comingRequestService base

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:
  - 47cdf2b refactor(ocpp20): isolate per-station state with
    WeakMap instead of singleton properties (introduces the WeakMap
    pattern with initial names 'OCPP20PerStationState' / 'stationStates'
    / 'getStationState')
  - f1e33ea refactor(ocpp20): harmonize per-station state naming
    (renames to 'OCPP20StationState' / 'stationsState')
  - 17396c1 refactor(ocpp20): rename getStationState to
    getOrCreateStationState (lazy-getter naming split)
  - c0b2555 fix(ocpp20): cancel cert-signing retry timer in stop()
  - be29b4c fix(ocpp20): add AbortController to log-upload lifecycle
    + stop() abort
  - d2e9b8b refactor(ocpp20): extract resetActiveFirmwareUpdateState
    helper
  - ce875da 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.
@jerome-benoit jerome-benoit force-pushed the refactor/ocpp-shared-per-station-state branch from f796062 to de2a08a Compare July 8, 2026 19:42
@jerome-benoit jerome-benoit merged commit 0fff8b2 into main Jul 8, 2026
44 checks passed
jerome-benoit added a commit that referenced this pull request Jul 8, 2026
The OCPP 1.6 UPDATE_FIRMWARE event listener schedules
updateFirmwareSimulation via setTimeout(...).unref() when retrieveDate
is in the future. .unref() prevents the timer from blocking process
exit, but the callback still fires after stop(): if the station
restarts, the deferred simulation runs against the new connection and
can emit FirmwareStatusNotification messages the CSMS did not request.

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

Mirrors the OCPP 2.0.1 pattern
(OCPP20IncomingRequestService.resetStationState cancels
certSigningRetryManager.cancelRetryTimer, commit c0b2555). Also
corrects the pre-existing logger source identifier in both branches of
the UPDATE_FIRMWARE listener from '.constructor:' to
'.updateFirmwareListener:' — the callback fires at runtime, not at
construction time.

Closes #1972
jerome-benoit added a commit that referenced this pull request Jul 8, 2026
The OCPP 1.6 UPDATE_FIRMWARE event listener schedules
updateFirmwareSimulation via setTimeout(...).unref() when retrieveDate
is in the future. .unref() prevents the timer from blocking process
exit, but the callback still fires after stop(): if the station
restarts, the deferred simulation runs against the new connection and
can emit FirmwareStatusNotification messages the CSMS did not request.

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

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

Closes #1972
jerome-benoit added a commit that referenced this pull request Jul 8, 2026
The OCPP 1.6 UPDATE_FIRMWARE event listener schedules
updateFirmwareSimulation via setTimeout(...).unref() when retrieveDate
is in the future. .unref() prevents the timer from blocking process
exit, but the callback still fires after stop(): if the station
restarts, the deferred simulation runs against the new connection and
can emit FirmwareStatusNotification messages the CSMS did not request.

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

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

Closes #1972
jerome-benoit added a commit that referenced this pull request Jul 8, 2026
The OCPP 1.6 UPDATE_FIRMWARE event listener schedules
updateFirmwareSimulation via setTimeout(...).unref() when retrieveDate
is in the future. .unref() prevents the timer from blocking process
exit, but the callback still fires after stop(): if the station
restarts, the deferred simulation runs against the new connection and
can emit FirmwareStatusNotification messages the CSMS did not request.

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

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

Closes #1972
jerome-benoit added a commit that referenced this pull request Jul 8, 2026
The OCPP 1.6 UPDATE_FIRMWARE event listener schedules
updateFirmwareSimulation via setTimeout(...).unref() when retrieveDate
is in the future. .unref() prevents the timer from blocking process
exit, but the callback still fires after stop(): if the station
restarts, the deferred simulation runs against the new connection and
can emit FirmwareStatusNotification messages the CSMS did not request.

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

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

Closes #1972
jerome-benoit added a commit that referenced this pull request Jul 8, 2026
)

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

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

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

Closes #1972
@jerome-benoit jerome-benoit deleted the refactor/ocpp-shared-per-station-state branch July 9, 2026 18:21
jerome-benoit added a commit that referenced this pull request Jul 9, 2026
…ction

Closes #1970

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

Three coordinated parts.

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

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

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

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

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

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

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

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

Part 3 — retry timer discipline in `sendQueuedSecurityEvents`:

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

Files touched (5 modified + 1 new):

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

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

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

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

References: #1983 (shared base plumbing), #1984 (OCPP 1.6
`deferredFirmwareUpdateTimer` timer discipline).
jerome-benoit added a commit that referenced this pull request Jul 9, 2026
…ction

Closes #1970

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

Three coordinated parts.

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

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

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

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

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

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

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

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

Part 3 — retry timer discipline in `sendQueuedSecurityEvents`:

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

Files touched (5 modified + 1 new):

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

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

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

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

References: #1983 (shared base plumbing), #1984 (OCPP 1.6
`deferredFirmwareUpdateTimer` timer discipline).
jerome-benoit added a commit that referenced this pull request Jul 9, 2026
…ction

Closes #1970

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

Three coordinated parts.

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

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

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

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

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

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

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

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

Part 3 — retry timer discipline in `sendQueuedSecurityEvents`:

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

Files touched (6 modified + 1 new):

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

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

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

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

References: #1983 (shared base plumbing), #1984 (OCPP 1.6
`deferredFirmwareUpdateTimer` timer discipline).
jerome-benoit added a commit that referenced this pull request Jul 9, 2026
…ction

Closes #1970

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

Three coordinated parts.

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

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

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

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

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

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

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

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

Part 3 — retry timer discipline in `sendQueuedSecurityEvents`:

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

Files touched (6 modified + 1 new):

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

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

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

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

References: #1983 (shared base plumbing), #1984 (OCPP 1.6
`deferredFirmwareUpdateTimer` timer discipline).
jerome-benoit added a commit that referenced this pull request Jul 9, 2026
…ction

Closes #1970

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

Three coordinated parts.

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

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

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

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

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

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

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

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

Part 3 — retry timer discipline in `sendQueuedSecurityEvents`:

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

Files touched (6 modified + 1 new):

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

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

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

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

References: #1983 (shared base plumbing), #1984 (OCPP 1.6
`deferredFirmwareUpdateTimer` timer discipline).
jerome-benoit added a commit that referenced this pull request Jul 9, 2026
…ction

Closes #1970

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

Three coordinated parts.

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

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

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

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

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

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

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

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

Part 3 — retry timer discipline in `sendQueuedSecurityEvents`:

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

Files touched (7 modified + 1 new):

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

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

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

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

References: #1983 (shared base plumbing), #1984 (OCPP 1.6
`deferredFirmwareUpdateTimer` timer discipline).
jerome-benoit added a commit that referenced this pull request Jul 9, 2026
…ction

Closes #1970

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

Three coordinated parts.

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

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

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

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

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

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

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

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

Part 3 — retry timer discipline in `sendQueuedSecurityEvents`:

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

Files touched (7 modified + 1 new):

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

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

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

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

References: #1983 (shared base plumbing), #1984 (OCPP 1.6
`deferredFirmwareUpdateTimer` timer discipline).
jerome-benoit added a commit that referenced this pull request Jul 9, 2026
…ction

Closes #1970

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

Three coordinated parts.

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

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

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

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

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

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

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

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

Part 3 — retry timer discipline in `sendQueuedSecurityEvents`:

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

Files touched (7 modified + 1 new):

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

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

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

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

References: #1983 (shared base plumbing), #1984 (OCPP 1.6
`deferredFirmwareUpdateTimer` timer discipline).
jerome-benoit added a commit that referenced this pull request Jul 9, 2026
…ction (#1992)

Closes #1970

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

Three coordinated parts.

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

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

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

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

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

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

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

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

Part 3 — retry timer discipline in `sendQueuedSecurityEvents`:

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

Files touched (7 modified + 1 new):

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

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

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

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

References: #1983 (shared base plumbing), #1984 (OCPP 1.6
`deferredFirmwareUpdateTimer` timer discipline).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[FEATURE] OCPP 1.6: introduce per-station state WeakMap (prerequisite for supersession + timer-cancel + trigger cross-check)

1 participant