Skip to content

refactor(hass): add lifecycle-managed integrations#4730

Draft
AlCalzone wants to merge 21 commits into
alcalzone-backend-refactor-hass-domainfrom
alcalzone-backend-refactor-hass-lifecycle
Draft

refactor(hass): add lifecycle-managed integrations#4730
AlCalzone wants to merge 21 commits into
alcalzone-backend-refactor-hass-domainfrom
alcalzone-backend-refactor-hass-lifecycle

Conversation

@AlCalzone

@AlCalzone AlCalzone commented Jul 11, 2026

Copy link
Copy Markdown
Member

Adds lifecycle owners for legacy MQTT discovery and @zwave-js/server.

  • Introduces managers for Home Assistant coordination, MQTT discovery, and server startup/teardown.
  • Keeps the existing Gateway, ZwaveClient, and plugin-facing facades.
  • Review focus: generation cancellation, subscription ownership, retryable teardown, and startup/shutdown order.

Part of #4722. Stacked on #4729.

@coveralls

coveralls commented Jul 11, 2026

Copy link
Copy Markdown

Coverage Report for CI Build 29569822048

Warning

No base build found for commit 1ca0240 on alcalzone-backend-refactor-hass-domain.
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: 44.601%

Details

  • Patch coverage: 13 uncovered changes across 4 files (269 of 282 lines covered, 95.39%).

Uncovered Changes

File Changed Covered %
api/runtime/AppRuntime.ts 67 62 92.54%
api/lib/Gateway.ts 14 10 71.43%
api/lib/MqttClient.ts 51 49 96.08%
api/lib/ZwaveClient.ts 18 16 88.89%
Total (10 files) 282 269 95.39%

Coverage Regressions

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


Coverage Stats

Coverage Status
Relevant Lines: 7215
Covered Lines: 3415
Line Coverage: 47.33%
Relevant Branches: 4592
Covered Branches: 1851
Branch Coverage: 40.31%
Branches in Coverage %: Yes
Coverage Strength: 32.93 hits per line

💛 - Coveralls

@AlCalzone
AlCalzone marked this pull request as draft July 11, 2026 21:11
@AlCalzone
AlCalzone force-pushed the alcalzone-backend-refactor-hass-domain branch from d0a95f8 to 48c2e1b Compare July 16, 2026 06:51
@AlCalzone
AlCalzone force-pushed the alcalzone-backend-refactor-hass-lifecycle branch from 16da52b to 7b7c46f Compare July 16, 2026 10:27
@AlCalzone
AlCalzone force-pushed the alcalzone-backend-refactor-hass-domain branch from 48c2e1b to d8adedf Compare July 16, 2026 13:01
@AlCalzone
AlCalzone force-pushed the alcalzone-backend-refactor-hass-lifecycle branch from 7b7c46f to 0209976 Compare July 16, 2026 13:33
@AlCalzone
AlCalzone force-pushed the alcalzone-backend-refactor-hass-domain branch from d8adedf to 7d7c6af Compare July 16, 2026 19:23
@AlCalzone
AlCalzone force-pushed the alcalzone-backend-refactor-hass-lifecycle branch from 0209976 to 331f854 Compare July 16, 2026 19:48
@AlCalzone
AlCalzone force-pushed the alcalzone-backend-refactor-hass-domain branch from 7d7c6af to 1ca0240 Compare July 17, 2026 08:49
AlCalzone and others added 19 commits July 17, 2026 11:04
…ycle

Move the official @zwave-js/server (ZwavejsServer) ownership out of the
8k-line ZwaveClient into a dedicated, strict-clean api/hass/ZwaveServerManager.

The manager owns the server instance and reproduces the exact former
behaviour behind a narrow ZwaveServerHost port (no ZwaveClient import, no
circular dependency, no broad casts):

- create(): default port 3000, serverHost -> host, inverted
  serverServiceDiscoveryDisabled -> enableDNSServiceDiscovery, error listener
  that swallows, hard reset listener that re-runs client init().
- startIfNeeded(): serverEnabled + instance + !server['server'] duplicate
  guard (fixes #602), start(!hasUserCallbacks), swallowed start rejection.
- destroy(): awaits server.destroy() before nulling so the driver teardown
  can await it; idempotent no-op when never created.
- handInclusionControlBack(): sockets-present guard for setInclusionUserCallbacks.
- version getter exposes the static serverVersion.

ZwaveClient keeps its public/internal facade unchanged: _createServer,
_startServerIfNeeded, close() teardown order, removeUserCallbacks and
getInfo().serverVersion now delegate, and `server` becomes a get/set accessor
over the manager so existing reach-ins keep working. Strict-audit diagnostics
drop 323 -> 321 (ZwaveClient 272 -> 270; new file 0).

Adds direct ZwaveServerManager.test.ts (19 cases, 100% stmts/branches/funcs/
lines) covering every option/guard/teardown/idempotency branch with event
barriers, plus per-file >=90/85 coverage thresholds in both vitest configs.
The end-to-end connect()->driver ready->close() flow stays green in
server.test.ts.

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

Move the legacy Home Assistant MQTT discovery subsystem out of Gateway into
a dedicated lifecycle-managed domain object. MqttDiscoveryManager now owns the
mutable `discovered` device index, the per-instance custom-device catalog fork,
the DiscoveryGenerator instance, and the scoped `homeassistant/status` /
broker-reconnect subscription that drives a full rediscovery.

Behavior is preserved exactly:
- Gateway keeps its public discovery facades and reach-in test surface via
  private compatibility accessors (`discoveryGenerator`, `customDeviceRegistry`,
  `discovered`) that delegate to the owned manager.
- Gateway builds the mqtt/zwave/topics ports (adapting the live clients) and
  passes them in, so the manager never binds to a concrete client.
- The catalog fork subscribes on start()/unsubscribes on stop(), preserving the
  single import-time watcher source and the 1/0/1/0 subscriber-count lifecycle.
- The status subscription returns an idempotent disposer and is wired by
  start()/torn down by stop(); the `homeassistant/status` topic subscription and
  case-insensitive online check remain in MqttClient, unchanged.

Adds 23 direct lifecycle/idempotency/scoped-unsubscribe/no-duplicate/
two-manager-isolation/store-isolation tests (100% stmts/branches/funcs/lines)
and per-file coverage thresholds in both vitest configs. Strict-audit total
drops 321 -> 320 (Gateway 46 -> 45); the new file is strict-clean.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Introduce the AppRuntime-owned coordinator that gives the built-in Home
Assistant subsystem a single process-lifetime owner without re-homing the
discovery or @zwave-js/server lifecycles (those stay locked to
Gateway.start()/close() and ZwaveClient.connect()/close()).

The coordinator exposes idempotent initialize/bind/start/stop steps and
resolves the CURRENT discovery/server sub-managers through always-current
resolvers, so a gateway or Z-Wave client replaced mid-restart is observed
on the next call with no stale capture. stop() only disposes the live
discovery status subscription (quiescing HA/broker status reactions before
the clients close); the structural teardown remains owned by
Gateway.close()/ZwaveClient.close() at their exact positions. Every step
tolerates absent collaborators, preserving the partial-failure / restart /
ordering guarantees.

Add a 16-test characterization suite (lifecycle, idempotency, current-
manager resolution, absent/partial collaborators) covering the new file
100% stmts/branches/funcs/lines, plus per-file coverage thresholds
(90/85/90/90) for api/hass/HomeAssistantManager.ts in both vitest configs.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Wire the HomeAssistantManager coordinator into AppRuntime so the built-in
Home Assistant subsystem has a deterministic owner across the process
lifetime and every restart:

  - construct + bind the coordinator once in the AppRuntime constructor,
    before any client exists, using always-current resolvers
    (getGateway()?.mqttDiscovery and getGateway()?.zwave?.zwaveServer);
  - initialize() at the top of startGateway(), before the MQTT/Z-Wave
    clients are constructed;
  - start() right after gw.start(), once the discovery + @zwave-js/server
    sub-managers are live;
  - stop() first in shutdown(), before the collaborators are closed, so HA
    status reactions are quiesced without racing the client teardown.

Expose the current sub-managers through public facade accessors so the
coordinator can resolve them without stale captures: Gateway.mqttDiscovery
(the live MqttDiscoveryManager) and ZwaveClient.zwaveServer (the live
ZwaveServerManager). The private internal accessors and the locked
discovery/server teardown positions are unchanged, so there is no behavior
change to either lifecycle.

Add 5 AppRuntime ordering/lifecycle tests (single coordinator, initialize-
then-start, no-gateway tolerance, stop-before-close ordering, current-
manager resolution across a replaced gateway).

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

Move the real `homeassistant/status` broker subscription out of unconditional
`MqttClient` ownership. `MqttClient` gains a generic scoped exact-topic
subscription API (`subscribeExact`) that returns an idempotent disposer,
(re)subscribes every registered exact topic on each `_onConnect`
(reconnect-safe), dispatches inbound exact-topic messages before any prefix
handling, and unsubscribes the broker topic when the last listener is disposed.
The unconditional `HASS_WILL` subscribe, the `_onMessageReceived` HASS branch
and the `hassStatus` event are removed.

`MqttDiscoveryManager` now owns the whole Home Assistant status concern: the
fixed `homeassistant/status` topic literal, the case-insensitive online check,
the `Home Assistant is ONLINE/OFFLINE` log, the legacy `Invalid payload sent to
Hass Will topic` complaint and the rediscovery. Its scoped subscription is
active only while the manager is started; `stop()` disposes it. The
`brokerStatus` reconnect handler stays.

Adds a real `unsubscribe` to the fake broker mock, reworks the
`MqttDiscoveryManager` status tests onto the scoped API, and adds a focused
`MqttClient.subscribeExact` characterization suite (subscribe/unsubscribe/
resubscribe, reconnect-exactly-once, shared listeners, no status after close).
The `mqttLifecycle` characterization suite passes unchanged.

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

Turn the discovery and @zwave-js/server sub-managers into components the
Gateway and ZwaveClient adopt from their owner rather than eagerly
construct themselves, so the HomeAssistantManager can own the live
generation while the clients stay compatibility delegates.

Gateway:
- extract the MqttDiscoveryManager wiring into a public
  buildDiscoveryOptions() so the owner can construct the manager against
  this gateway's live clients;
- make _mqttDiscovery lazy: the getter builds a standalone fallback only
  when nothing was adopted (direct construction / tests), and
  adoptDiscoveryManager() lets the owner inject the current instance
  before start(). Gateway.start()/close() still drive discovery at the
  locked timing points, now on the adopted instance.

ZwaveClient:
- extract buildServerHost() (the narrow ZwaveServerHost port) and make
  _serverManager lazy: the zwaveServer getter builds a standalone
  fallback for the direct consumers (_createServer/_startServerIfNeeded
  and the compatibility server accessor), while close()/handInclusion/
  getInfo stay guarded (no build) and adoptServerManager() lets the
  owner inject the current instance before the driver connects;
- read serverVersion from the package directly in getInfo() so it stays
  present regardless of whether a manager was built.

No timing changes: server is still created after driver start, started
after ready/restore with start(!hasUserCallbacks), and destroyed before
the driver in close(); discovery still starts at Gateway.start(). Pure
ownership seam so the full 259-test hass suite stays green.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
… and centralize lifecycle in AppRuntime

Rework the Home Assistant coordinator from a hollow resolver-based facade
into the single, process-lifetime OWNER of the built-in HA subsystem, and
route every restart/close through one centralized AppRuntime teardown so
the quiesce-before-close ordering is unconditional.

HomeAssistantManager now genuinely constructs and holds a generation of
the discovery and @zwave-js/server sub-managers through injected
factories (which also adopt them into the current Gateway/ZwaveClient),
driving them with an explicit, idempotent state machine
(idle -> initialized -> starting -> started -> stopping -> initialized,
plus failed). It owns the handles and disposers; stop() halts discovery
producers/listeners/subscriptions and AWAITS the server destroy, then
settles back to a restartable resting state. Concurrent stops share one
in-flight teardown; a restart attaches a brand-new generation so nothing
stale survives; markFailed() keeps the partially-started generation so a
subsequent stop still quiesces it.

AppRuntime creates the manager before any client, injects the
construct+adopt factories in startGateway() (before the clients start,
preserving discovery-at-Gateway.start and server-at-driver-ready timing),
confirms start() after gw.start(), and on a startup failure marks failed
then quiesces before rethrowing. A new teardownGateway() serializes
against any in-flight start, quiesces HA, then closes the gateway and
destroys plugins; shutdown() and /api/restart both route through it. The
former lone `restarting` boolean is replaced by a GatewayLifecycleState
machine (idle/starting/started/stopping/failed) that reproduces the exact
observable isRestarting() behavior (boot-time start never reported as
restarting; an explicit restart stays "restarting" across teardown and
its subsequent start).

Tests: rewrite the HomeAssistantManager unit suite for genuine ownership
(direct concrete ownership, exact teardown call order, idempotency from
every state, concurrent-stop de-duplication, restart/fresh-generation,
partial attach, failed-then-quiesce); rework the AppRuntime HA-lifecycle
suite for construct/adopt/own, quiesce-before-close ordering, restart
fresh generation, startup-failure quiescence and shutdown-during-start
serialization; add the HA-ownership hooks to the settings-boundary mocks.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Guard `destroy()` so concurrent calls for the same captured server share a
single upstream `server.destroy()`, the reference is cleared only when the
destroyed instance is still current (a replacement adopted mid-teardown
survives), and a rejected teardown propagates to the caller while leaving the
reference retained so a later `destroy()` can retry.

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

Capture the generation being quiesced so the teardown clears the owned
discovery/server handles and resets state only when that generation is still
current: an overlapping re-attach wired while the stop awaits the server
destroy is no longer erased by the stale stop. A rejected server destroy now
retains the handles (retryable), moves the subsystem to `failed` (observable)
and re-throws, while the in-flight guard is always released so a later stop
retries the teardown.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Add a publication fence to `DiscoveryGenerator` (`activate`/`deactivate`)
guarding the single retained-publication choke point plus the status-driven
`rediscoverAll`, and drop it synchronously as the first action of
`MqttDiscoveryManager.stop()` - before the status subscription/watcher are
disposed and before the coordinator awaits the server destroy. This quiesces
every node/value/remove/status producer from the instant a stop begins, so no
retained discovery message is published during the deferred teardown window. A
restart re-arms the fence (matters for the standalone Gateway path that reuses
the same generator instance).

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Re-expose the public `hassStatus` event on `MqttClient` (restored to
`MqttClientEventCallbacks` for plugin typechecks) without re-adding the
unconditional `homeassistant/status` broker subscription the refactor removed.
The discovery subsystem still owns the single scoped status subscription and
now routes the compatibility emit back through `MqttClient.emitHassStatus` from
its status-message handler, so a concrete-client plugin's
`mqtt.on('hassStatus')` observes the same boolean value and once-per-message
cadence as before - with no duplicate broker ownership and no spurious emit on
broker reconnect.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Route `subscribeExact`/`_onConnect` exact-topic subscribes through a dedicated
desired-state-aware helper that never feeds the blanket `toSubscribe` retry
set: its broker callback re-checks `exactSubscriptions`/`closed`, so a subscribe
that completes (successfully or with an error) after the owner disposed the
topic - or after the client closed - can neither requeue the topic for a
reconnect nor leave it subscribed on the broker. A still-desired topic that
failed is retried on the next connect. `close()` now unsubscribes the desired
exact broker topics before clearing/ending (so a clean:false session isn't left
with a lingering server-side subscription), and the standalone `Gateway.close`
quiesces discovery before closing MQTT so the status subscription is torn down
while still connected.

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

Two coupled gateway start/teardown lifecycle fixes that share one
generation/cancellation model in `AppRuntime`:

Failed-start cleanup: when `gw.start()` throws, the runtime now closes the
EXACT failed generation - it quiesces the partially-started Home Assistant
subsystem AND closes the gateway (Z-Wave driver + MQTT client) and destroys
any plugins - before rethrowing, so a failed start no longer leaks the open
MQTT connection, the Z-Wave driver, or the `@zwave-js/server` port. Each
cleanup step is isolated and its error is logged/aggregated; a cleanup failure
never replaces the caller's original startup error.

Generation-scoped cancellation: each `startGateway()` mints a `StartGeneration`
token; a concurrent teardown/shutdown/restart cancels it and tears down
PROMPTLY instead of `await`-ing the in-flight start (which deadlocked when
`gw.start()` or a plugin top-level `await import()` never settled). Closing the
gateway unblocks a hung `gw.start()`; whenever the start finally settles -
immediately, late, or never - `runStartGateway` re-checks the token at each
continuation checkpoint (after the awaited `gw.start()`, and around each awaited
plugin import) and bails without starting HA, adopting, or publishing. A
cancelled start's late rejection is swallowed (its caller sinks it, so no
unhandled rejection) and never flips the lifecycle to `failed`; the teardown
owns the cleanup exactly-once.

Rewrites the former "serialize behind the in-flight start" teardown test (the
serialize-vs-cancel semantics are mutually exclusive; cancellation is the
mandated behavior) and adds deterministic coverage: failed-start gateway close
(with/without Z-Wave, and with a throwing cleanup that preserves the primary
error), prompt cancellation of a never-resolving `gw.start()`, a cancelled
start that later resolves or rejects, a hung plugin import that a teardown does
not wait for and never adopts, and a fresh restart generation that a stale
cancelled start cannot corrupt.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Reduce the comments added across the Home Assistant lifecycle managers, AppRuntime, the MQTT/Z-Wave facades and their tests to the non-obvious why. Drop restatements, decorative dividers, changelog phrasing, capitalized emphasis and parenthetical asides so names and code carry the obvious facts.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Replace the standalone ZwaveServerConfig interface with a Pick of the client's own ZwaveConfig, so the @zwave-js/server field slice cannot drift from the upstream configuration type it is derived from.

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

Reword the /api/restart teardown and graceful-shutdown comments to state why the strict close path is used (a restart with no gateway attached is a caller error) and why shutdown stays guarded, instead of characterizing the native TypeError as behaviour to preserve. No runtime behaviour changes.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Delete tests whose subject was adopted-manager identity, facade delegation, accessor round-trips or the process-lifetime coordinator handle rather than externally observable behaviour. The adoption/facade-routing blocks revert facades and server tests to base; the discovery and server manager suites keep only the live-index and re-read-on-create behaviour; the AppRuntime dormant-coordinator identity test is dropped. Lifecycle, MQTT, server, discovery and restart behaviour stays covered through production entry points.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Retitle cases and rewrite comments to describe observable behavior rather than internal method/field coordinates, and drop an assertion that only restated the status topic constant already proven by the subscription behavior.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The new Home Assistant lifecycle suites (HomeAssistantManager,
MqttDiscoveryManager, ZwaveServerManager, mqttScopedSubscription) and the
two hass mocks added to settingsConstructorBoundary reached into the
backend with deep relative paths while every sibling hass/runtime suite on
the merged discovery-domain base imports production modules through the
canonical `#api/*` alias. Point them at `#api/*` so the whole suite shares
one resolution convention with no behavior change.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
AlCalzone and others added 2 commits July 17, 2026 11:04
The rebased runtime builds a discovery manager from the gateway's
buildDiscoveryOptions() and adopts it through adoptDiscoveryManager() during
startGateway(), so the shared fake gateway must expose both. Provide an inert
but valid MqttDiscoveryManagerOptions (disabled MQTT and a real, unstarted
registry source so the manager's fork() installs no file watchers) so the
startGateway()-driven runtime, auth and plugin suites exercise the real
discovery-adoption path with no MQTT or file I/O.

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

Complete the manager-owned wiring for the subsystems the refactor extracts.

The factory hands the started root custom-device registry to the gateway as the discovery source. The gateway forwards it to the discovery manager, which forks it exactly once; forking earlier would subscribe the manager to a fork that never re-emits, silently breaking live custom-device reloads.

The gateway exposes its discovery-option builder and manager-adoption entry point on its runtime port, plus the plugin-facing Home Assistant status MQTT surface, so the coordinator can construct and adopt the manager it owns. The client creates and starts the official server through the extracted server manager, preserving the single-owner lifecycle and the duplicate-start guard.

The centralized gateway teardown selects its close path with a boolean flag so a restart with no gateway attached surfaces as a caller error, and the failed-start quiesce is typed against the same runtime port the factory returns, keeping every runtime collaborator on the port boundary.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@AlCalzone
AlCalzone force-pushed the alcalzone-backend-refactor-hass-lifecycle branch from 331f854 to 68413f5 Compare July 17, 2026 09:23
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