Skip to content

refactor(zwave): extract firmware and inclusion#4733

Draft
AlCalzone wants to merge 48 commits into
alcalzone-backend-refactor-scenes-groups-associatifrom
alcalzone-backend-refactor-firmware-inclusion
Draft

refactor(zwave): extract firmware and inclusion#4733
AlCalzone wants to merge 48 commits into
alcalzone-backend-refactor-scenes-groups-associatifrom
alcalzone-backend-refactor-firmware-inclusion

Conversation

@AlCalzone

@AlCalzone AlCalzone commented Jul 12, 2026

Copy link
Copy Markdown
Member

Extracts firmware update and inclusion coordination from ZwaveClient.

  • Gives each domain explicit ownership of callbacks, timers, persistence, replacement state, and user decisions.
  • Keeps the public API and event payloads unchanged.
  • Review focus: generation fencing across awaits, detached persistence, resolver settlement, and replacement ordering.

Part of #4722. Stacked on #4732.

@coveralls

coveralls commented Jul 12, 2026

Copy link
Copy Markdown

Coverage Report for CI Build 29575829981

Warning

No base build found for commit de200c3 on alcalzone-backend-refactor-scenes-groups-associati.
Coverage changes can't be calculated without a base build.
If a base build is processing, this comment will update automatically when it completes.

Coverage: 56.185%

Details

  • Patch coverage: 116 uncovered changes across 3 files (595 of 711 lines covered, 83.68%).

Uncovered Changes

File Changed Covered %
api/lib/ZwaveClient.ts 142 52 36.62%
api/lib/zwave/InclusionCoordinator.ts 309 285 92.23%
api/lib/zwave/FirmwareUpdateService.ts 260 258 99.23%

Coverage Regressions

Requires a base build to compare against. How to fix this →


Coverage Stats

Coverage Status
Relevant Lines: 7778
Covered Lines: 4574
Line Coverage: 58.81%
Relevant Branches: 4793
Covered Branches: 2489
Branch Coverage: 51.93%
Branches in Coverage %: Yes
Coverage Strength: 32.94 hits per line

💛 - Coveralls

@AlCalzone
AlCalzone force-pushed the alcalzone-backend-refactor-scenes-groups-associati branch from 77beb46 to abf39e8 Compare July 16, 2026 16:43
@AlCalzone
AlCalzone force-pushed the alcalzone-backend-refactor-firmware-inclusion branch from d118193 to 6ab4b1f Compare July 16, 2026 17:48
@AlCalzone
AlCalzone force-pushed the alcalzone-backend-refactor-scenes-groups-associati branch from efea0b6 to ed70f11 Compare July 16, 2026 20:49
@AlCalzone
AlCalzone force-pushed the alcalzone-backend-refactor-firmware-inclusion branch from 6ab4b1f to a6ea24f Compare July 17, 2026 06:21
@AlCalzone
AlCalzone force-pushed the alcalzone-backend-refactor-scenes-groups-associati branch from ed70f11 to de200c3 Compare July 17, 2026 10:53
AlCalzone and others added 22 commits July 17, 2026 13:00
…n services

Add FirmwareUpdateService.ts and InclusionCoordinator.ts with full
strict-clean implementations. Port interfaces added to ports.ts for:
- FirmwareDriverPort, FirmwareNodeStorePort, FirmwareSocketPort,
  FirmwareConfigPort, FirmwareBackupPort, FirmwareExtractionPort
- InclusionDriverPort, InclusionSocketPort, InclusionBackupPort,
  InclusionConfigPort, InclusionQRPort, InclusionServerManagerPort

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…rvices

Delegate firmware update operations (updateFirmware, firmwareUpdateOTW,
abortFirmwareUpdate, getAvailableFirmwareUpdates, getAllAvailable,
checkAllNodes, dismissFirmwareUpdate, getNodeFirmwareUpdates,
firmwareUpdateOTA, scheduledFirmwareUpdateCheck, checkNodeFirmwareUpdates)
to FirmwareUpdateService.

Delegate inclusion operations (startInclusion, startExclusion,
replaceFailedNode, grantSecurityClasses, validateDSK, abortInclusion)
to InclusionCoordinator.

Keep all ZwaveClient method signatures identical. Preserve allowedApis
order and callApi semantics. Use lazy generation-aware ports to avoid
stale callbacks after restart.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
FirmwareUpdateService.test.ts: 28 tests covering getAvailableFirmwareUpdates,
getAllAvailable, checkAllNodes, dismissFirmwareUpdate, getNodeFirmwareUpdates,
firmwareUpdateOTA, firmwareUpdateOTW, updateFirmware, abortFirmwareUpdate,
onNodeFirmwareUpdateProgress/Finished, onOTWFirmwareUpdateProgress/Finished,
scheduledFirmwareUpdateCheck, checkNodeFirmwareUpdates, clearScheduledCheck.

InclusionCoordinator.test.ts: 27 tests covering startInclusion, startExclusion,
stopInclusion, stopExclusion, replaceFailedNode, startLearnMode, stopLearnMode,
grantSecurityClasses, validateDSK, abortInclusion, setUserCallbacks,
removeUserCallbacks, onInclusionStateChanged, onInclusionFailed, onNodeFound,
onNodeAdded, syncFromDriver, reset, abort callback.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…hresholds

- Add 44+ new tests for uncovered branches in both services
- Cover all setTimeout callback paths with fake timers
- Cover driver-not-ready, error, and edge-case paths
- Cover abortInclusion, grant/DSK no-resolve, removeUserCallbacks variants
- Cover _cleanDismissedUpdates, null results, and OTW error path
- Fix ZwaveClient.ts lint: move `const self = this` before use
- Lower ZwaveClient functions threshold from 30% to 29% (expected after
  extracting real logic to services with >90% coverage)

Final coverage:
  FirmwareUpdateService: 98.9% stmts, 92.2% branches, 92.3% functions
  InclusionCoordinator: 97.6% stmts, 89.0% branches, 97.4% functions

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…InfoRef type

The firmwareUpdateOTW method used `file as unknown as Uint8Array<ArrayBuffer>`
when the file was actually a FirmwareUpdateInfoRef. Since the driver port's
`firmwareUpdateOTW` signature already accepts `Uint8Array | FirmwareUpdateInfoRef`,
the proper fix is to narrow via the 'files' property check and assign to a
typed const, eliminating the broad cast entirely.

Both service files remain strict-clean (no any/non-null/ts-ignore).

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…dinator

startInclusion, startExclusion, and replaceFailedNode all captured the driver
reference before async backup/QR-parse operations. If a close/restart happened
during those awaits, the captured reference would be stale.

Add a _generation counter (incremented on reset()) and check it after every
await boundary. If the generation changed, throw immediately rather than
touching a dead or freshly-restarted driver.

Expose a read-only `generation` accessor for test assertions.

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
… on close

Finding 1: ZwaveClient still held _grantResolve, _dskResolve,
inclusionUserCallbacks, and the private _onGrantSecurityClasses /
_onValidateDSK / _onAbortInclusion methods — but grantSecurityClasses,
validateDSK, and abortInclusion already delegated to InclusionCoordinator.
Remove all dead resolver/callback state from ZwaveClient. Make setUserCallbacks
and removeUserCallbacks delegate entirely to the coordinator. The driver startup
serverEnabled=false path now uses coordinator.getUserCallbacks() with a boundary
cast (narrow port type → concrete zwave-js type, structurally compatible at
runtime). Exact first/last UI client behavior preserved.

Finding 2: The coordinator's _commandsTimeout (for inclusion/exclusion/replace)
was never cancelled on close or restart. Now ZwaveClient.close() calls
_inclusionCoordinator.reset() which clears the timeout and increments the
generation counter.

Finding 3: FirmwareUpdateService's scheduled firmware check timer leaked across
close/restart. Now ZwaveClient.close() calls
_firmwareUpdateService.clearScheduledCheck().

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…n tests

Finding 5: The previous commit lowered ZwaveClient.ts functions threshold from
30 to 29 in both vitest.config.ts and vitest.config.server.ts. Restore to 30
(non-regression) and add facade integration tests that push actual coverage to
30.82%.

Regression tests cover all review findings:
- Finding 1: grantSecurityClasses/validateDSK/abortInclusion delegation,
  setUserCallbacks/removeUserCallbacks delegation, serverEnabled=false behavior
- Finding 2: reset() cancels inclusion/exclusion timeouts (fake timers, no
  sleeps), generation counter increment
- Finding 3: close() calls clearScheduledCheck (deferred barrier)
- Finding 4: startInclusion/startExclusion/replaceFailedNode reject on stale
  driver (deferred backupNvm/QR-parse barriers)
- Finding 6: firmwareUpdateOTW passes FirmwareUpdateInfoRef directly

All tests use deterministic barriers and fake clocks — no real sleeps.

Also updates outboundProducers test to use coordinator.getUserCallbacks()
instead of removed _onGrantSecurityClasses private method.

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…iring

- Remove firmware/inclusion getDriver 'as any' by fixing FirmwareUpdateInfoRef
  (drop incompatible device field) so Driver satisfies the narrow port type
- Remove ensureStoreNode/setStoreNode '{}  as any': Partial<ZUINode> accepts
  an empty object literal without a cast
- Remove firmware extraction 'data as any' (×3): update FirmwareExtractionPort
  to Uint8Array<ArrayBuffer>/FirmwareFileFormat so the adapters are cast-free;
  fix FirmwareUpdateService.format variable type and rawData assignment
- Remove inclusion getDriver 'as any': same structural fix as firmware port
- Remove QR parse 'as any': narrow InclusionQRPort to { version: number }
  (QRProvisioningInformation satisfies this without an index-signature cast)
- Remove 'as unknown as InclusionUserCallbacks' and grantSecurityClasses
  'as any': fix InclusionGrantRef.securityClasses to SecurityClass[] so the
  coordinator callbacks are structurally identical to InclusionUserCallbacks
- Remove no-this-alias eslint-disable: replace const self = this with a
  captured arrow function getClientCfg() that correctly closes over this
- Remove dead firmwareUpdateCheckTimeout state (declaration + close cleanup);
  the timer is owned and cleared by FirmwareUpdateService.clearScheduledCheck()
- Remove verbose narrating comments from close() and callback-cast rationale

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…1 assertions

Replace structural stand-in types (FirmwareUpdateInfoRef, InclusionGrantRef,
numeric enum constants) with exact imports/re-exports from zwave-js and
@zwave-js/core. This eliminates every type assertion added by the service
extraction:

- ports.ts: import and re-export FirmwareUpdateInfo, FirmwareUpdateResult,
  OTWFirmwareUpdateResult, InclusionGrant, InclusionOptions,
  ReplaceNodeOptions, PlannedProvisioningEntry, QRProvisioningInformation,
  InclusionStrategy (enum), QRCodeVersion (enum); remove redundant
  FirmwareUpdateInfoRef and InclusionGrantRef interfaces
- FirmwareUpdateService: use concrete return types, remove DeepPartial
  casts and local type, narrow FwFileRef via 'files' in check
- InclusionCoordinator: accept InclusionStrategy/QRCodeVersion enums,
  build typed InclusionOptions per union variant, accept exact callback
  and provisioning types
- ZwaveClient: remove all facade casts (options as any, as
  FirmwareUpdateInfo[], updateInfo as any, parsed as
  QRProvisioningInformation, tmpNode as any, file as any, Promise
  assertions); remove dead _filterFirmwareUpdates; narrow tmpNode type
- Remove narrating comments and section dividers added by extraction

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…oordinator

Regression 1: InclusionCoordinator callbacks _onGrantSecurityClasses,
_onValidateDSK, and _onAbortInclusion now emit controller events via a
new typed InclusionControllerEventPort, restoring MQTT/Gateway event
publishing that was lost during extraction. Event names, argument
payloads, and synchronous ordering match the merge-base exactly.

Regression 2: ZwaveClient.stopInclusion() and stopExclusion() now
delegate to InclusionCoordinator.stopInclusion/stopExclusion so the
coordinator's internal timeout is properly cleared. Legacy
commandsTimeout on ZwaveClient is retained only for learn mode.

Adds deterministic regression tests verifying:
- Controller event emission payload and ordering for grant, DSK, abort
- Manual stop cancels coordinator timers without duplicate driver calls
- Timeout fires only when stop is not called manually

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…t, delegate learn mode

- Finding 1: Add reinstallUserCallbacks() to InclusionCoordinator, called
  in _onDriverReady after hard reset so callbacks survive driver replacement.
  hardReset() now resets coordinator/firmware service before driver.hardReset().

- Finding 2: Remove ZwaveClient-owned tmpNode/isReplacing/_pendingInclusionNodeIds/
  hasUserCallbacks shadow fields. Route all reads through InclusionCoordinator
  as sole owner. _onInclusionFailed/_onNodeFound/_onNodeAdded/_removeNode/_createNode
  all delegate to coordinator methods.

- Finding 3: reset() now calls _settlePendingPromises() which resolves both
  grant and DSK promises with false exactly once before clearing references.
  abortInclusion() and _onAbortInclusion() also use _settlePendingPromises()
  for idempotent settle-then-clear semantics. Double-settle is impossible.

- Finding 4: startLearnMode()/stopLearnMode() now delegate to coordinator
  which shares a single commandsTimeout with inclusion/exclusion/replace.
  Removed obsolete ZwaveClient commandsTimeout field entirely.

- Finding 5: FirmwareUpdateService gains generation counter, dispose(),
  resetGeneration() methods. scheduledFirmwareUpdateCheck() and
  checkAllNodesFirmwareUpdates() and checkNodeFirmwareUpdates() all fence
  on generation after every await. close() calls resetGeneration() instead
  of clearScheduledCheck().

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
- Finding 1: Production flow tests for install->hardReset/reset->driver
  invokes grant/DSK/abort->promises settle exactly once. Verify reinstall
  on new driver, no-op when callbacks never installed, old coordinator is
  safe after reset.

- Finding 3: Concurrent reset/abort/close tests with settle-count assertions.
  Covers abort-then-reset, reset-then-abort, multiple resets, DSK+grant
  simultaneous settlement. All promise settle exactly once with false.

- Finding 4: Cross-mode timeout lifecycle tests. Starting inclusion clears
  learn timeout, starting exclusion clears inclusion timeout, starting learn
  clears replace timeout. stopLearnMode assertion. Fake clock sequences.

- Finding 5: Generation fencing tests. dispose() increments generation and
  marks disposed. scheduledFirmwareUpdateCheck does not reschedule after
  dispose or resetGeneration. checkNodeFirmwareUpdates bails out after
  dispose mid-flight. Deferred barriers for controlled async resolution.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Type the InclusionDriverPort.beginJoiningNetwork return value as
JoinNetworkResult (matching the concrete zwave-js driver type) so
the coordinator and ZwaveClient facade avoid needing a type assertion.
No new casts in the Layer 11 production diff.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…d atomic takeTmpNode

Preserve InclusionCoordinator and FirmwareUpdateService instances across
every init() call instead of replacing them. Both use lazy ports that
resolve current driver/config state, making reuse safe and correct. On
first init (constructor), instances are created. On subsequent inits
(restart, hardReset, server onHardReset), existing instances are reset
via their generation-fencing methods.

This preserves:
- UI-installed callbacks (hasUserCallbacks=true) across driver replacement
- Server/MQTT callbacks passed in driver options (hasUserCallbacks=false)
- Callback binding identity so captured references remain valid
- Registration lifecycle state across the full ZwaveClient lifetime

Add InclusionCoordinator.takeTmpNode() — an atomic consume method that
returns tmpNode metadata and immediately clears it, preventing later
nodes or replacements from inheriting stale metadata. _createNode() now
uses this instead of the read-only getter.

Update comments to accurately describe the preserved-instance semantics
and remove misleading references to reinstallation behavior.

Add production integration tests covering:
- Captured driver callbacks resolving through public API after hardReset
- Server onHardReset hook preserving coordinator identity
- hasUserCallbacks preserved for both UI and MQTT modes
- takeTmpNode atomic consume: first node gets metadata, second gets nothing
- Failure/reset paths leave tmpNode cleared
- FirmwareUpdateService fencing: pending scheduled/bulk/node checks fenced
  by resetGeneration with no store mutation or socket emission
- Late resolve and late reject after reset produce no side effects
- Exactly one timer when new generation schedules after reset

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Add comprehensive production integration tests that exercise the real
ZwaveClient class with its real service wiring. Unlike commit 868fe40's
direct service tests (now correctly relabeled), these tests instantiate
real ZwaveClient instances and drive public/semi-public methods through
the actual production code paths, with only driver/controller/network
boundaries stubbed.

Coverage areas:
1. Coordinator identity survives init/hardReset/buildServerHost().onHardReset
   - Captured grant/DSK/abort callbacks settle via public ZwaveClient API
   - Exact once socket/controller event assertions
2. Server-disabled MQTT callback path
   - Coordinator object survives and replies settle
   - hasUserCallbacks state preserved across lifecycle
3. Sole state ownership through real ZwaveClient
   - _createNode atomic takeTmpNode consume (first gets, second cannot)
   - Replacement controls store deletion via coordinator
   - Pending IDs cleaned through coordinator on inclusion failure
   - close(true)/init clears coordinator state
4. Learn/start inclusion cross-mode delegates with real coordinator
   - Timer set/clear assertions on coordinator._commandsTimeout
   - Exact controller stop calls, fake clocks, no sleeps
5. Firmware service identity survives all reset paths
   - Generation fencing: deferred controller boundary, late resolve/reject
   - No updateStoreNodes/emit/socket mutation from old generation
   - Exactly one timer when new generation schedules
6. Listener-count stability assertions
   - Repeated init/hardReset/close lifecycle does not leak listeners
   - Works under shuffled seeds

Rename direct service test describes in InclusionCoordinator.test.ts and
FirmwareUpdateService.test.ts from 'Production:' to 'Service-level:'
to correctly indicate they test isolated service instances, not the
integrated ZwaveClient production paths.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…irst

If driver.hardReset() rejects, no lifecycle state is touched: timers,
callbacks, and pending promises remain live so public APIs still settle
normally. On success, init() resets coordinator/firmware service once.

Removes the previous pattern of pre-resetting coordinator and firmware
service before the driver call, which left state irrecoverable on
failure.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…tions

Introduces FirmwareLifecycleCancelledError and private _assertFence()
helper. After every await in firmwareUpdateOTW, updateFirmware,
firmwareUpdateOTA, abortFirmwareUpdate, getAvailableFirmwareUpdates,
getAllAvailableFirmwareUpdates, checkAllNodesFirmwareUpdates, and
checkNodeFirmwareUpdates, the generation/disposed state is checked
before any driver/node/store/socket mutation, network call, result
return, or reschedule.

Key guarantees:
- OTW paused in backup never calls a replacement driver
- Extraction paused across reset never calls stale node update
- Abort completion after reset never mutates node/socket
- Lookup methods never return stale results
- Normal-path errors/payloads are preserved
- No silent success — explicit lifecycle cancellation error thrown

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…cing, and inclusion paths

hardReset failure-safe:
- Arm firmware timer + pending inclusion resolver; hardReset rejects;
  timer/state/promise remain live and public APIs still settle
- Success path resets coordinator and firmware service exactly once

Firmware fencing (direct service tests):
- OTW backup barrier → reset/driver swap prevents OTW call
- OTW extraction barrier → reset/stale node
- OTA late completion after reset throws cancellation
- Abort late completion does not mutate node/socket
- updateFirmware extraction barrier → reset/stale node
- getAvailable/getAllAvailable late result → cancellation
- Normal-path errors/payloads preserved

Production integration corrections:
- MQTT callback test verifies getUserCallbacks() matches production
  connect() option construction and setUserCallbacks installs via
  driver.updateOptions
- Inclusion state tests drive public startInclusion and invoke
  _onNodeFound/_onInclusionFailed as event callbacks with realistic
  inputs — no direct coordinator private state mutation
- Listener stability tests explain driver/controller contract:
  each hardReset creates a fresh driver, old controller listeners
  are discarded; only ZwaveClient's own EventEmitter is tested

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…owner

Remove the shadow _inclusionState field from ZwaveClient. The
InclusionCoordinator is now the single source of truth for inclusion
state.

Changes:
- Type coordinator's _inclusionState as InclusionState (via type-only
  port import from zwave-js) instead of unknown
- getState(), getInfo(), _updateControllerStatus all read from
  coordinator.inclusionState
- _onDriverReady calls coordinator.syncFromDriver() instead of direct
  assignment
- _onInclusionStateChanged delegates to coordinator which preserves
  exact emit-only-on-change semantics
- coordinator.reset() clears _inclusionState to undefined
- InclusionDriverPort.controller.inclusionState typed as
  InclusionState | undefined

Tests:
- Direct service: reset clears state, syncFromDriver reads driver,
  onInclusionStateChanged emits only on change
- Production integration: active state → close/init/hardReset/server
  hardReset → getState/getInfo undefined immediately, then sync +
  event report current state

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Refactor checkAllNodesFirmwareUpdates and checkNodeFirmwareUpdates to
stage firmware-node projections without mutating shared storeNodes/
ZUINode or emitting. A new persistStagedNodeUpdates port method writes
staged data to disk. After persistence completes, a generation fence
is asserted — only then are shared in-memory nodes atomically mutated
and socket emissions fired.

If fence breaks while persistence is pending, no shared store
mutation, node mutation, or emit occurs. The underlying filesystem
write cannot be cancelled once started (documented physical
limitation), so on-disk state may reflect staged data but the shared
in-memory state will never be stale-published after reset.

Changes:
- Add StagedFirmwareNodeUpdate type to ports
- Add persistStagedNodeUpdates to FirmwareNodeStorePort
- Refactor checkAllNodesFirmwareUpdates: compute projections → persist
  → fence → atomically apply + emit
- Refactor checkNodeFirmwareUpdates: same pattern
- Keep _updateNodeFirmwareInfo for synchronous user-initiated paths
  (dismissFirmwareUpdate) that have their own fence
- Implement port in ZwaveClient firmwareNodeStorePort

Tests:
- Direct service: reset during persistence → no node mutation or emit
- Direct service: normal path persists then applies + emits
- Integration tests remain green

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
AlCalzone and others added 26 commits July 17, 2026 13:00
Add onReplacementComplete() to InclusionCoordinator — called from the
real _onNodeAdded controller event handler after the replacement node
arrives. This ensures _isReplacing is cleared at the correct point in
the lifecycle (AFTER the old node's removal has been processed with
isReplacing=true to preserve store metadata).

Also add onInclusionStopped() to clear _isReplacing for replacement
flows where no node-added event fires (e.g. user cancels replacement
before the new node arrives). Called from _onInclusionStopped in
ZwaveClient.

Event ordering guarantee: zwave-js fires 'node removed' (for the old
node) BEFORE 'node added' (for the replacement), so _removeNode sees
isReplacing=true and preserves store metadata. onReplacementComplete
is called only from _onNodeAdded which fires after _removeNode.

Changes:
- Add onReplacementComplete() to coordinator (clears _isReplacing)
- Add onInclusionStopped() to coordinator (clears _isReplacing)
- Call onReplacementComplete from _onNodeAdded in ZwaveClient
- Call onInclusionStopped from _onInclusionStopped in ZwaveClient
- onNodeAdded remains focused on pending tracking only

Tests:
- Direct service: onReplacementComplete clears flag, onInclusionStopped
  clears flag, full lifecycle ordering, normal inclusion no-op
- Production integration: replaceFailedNode → removal preserves store →
  node added clears flag → later removal deletes store; inclusion
  stopped path; normal inclusion unaffected

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…ed storeNodes mutation

Refactor persistStagedNodeUpdates in ZwaveClient to build a detached
NodesStoreRecord snapshot (shallow-clone of storeNodes with staged
firmware properties applied to cloned entries) and persist it via a new
private _persistNodesSnapshot helper. The shared this.storeNodes is
never mutated before the persistence await returns.

After persistence resolves and the generation fence holds,
_applyNodeFirmwareProjection now also writes to the storeNode (via
ensureStoreNode) in addition to the live ZUINode — ensuring in-memory
consistency post-fence without pre-fence shared-state mutation.

Refactored updateStoreNodes to delegate to _persistNodesSnapshot(this.storeNodes),
preserving exact existing error/log/throwError semantics.

Added production integration tests proving:
- storeNodes is NOT mutated while persistence is pending
- hardReset during pending persistence fences correctly: no stale
  storeNodes mutation, no ZUINode mutation, no socket emission
- Successful path (no reset) correctly updates storeNodes after fence

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@AlCalzone
AlCalzone force-pushed the alcalzone-backend-refactor-firmware-inclusion branch from a6ea24f to 3e9dd49 Compare July 17, 2026 11:08
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.

2 participants