refactor(hass): add lifecycle-managed integrations#4730
Draft
AlCalzone wants to merge 21 commits into
Draft
Conversation
Coverage Report for CI Build 29569822048Warning No base build found for commit Coverage: 44.601%Details
Uncovered Changes
Coverage RegressionsRequires a base build to compare against. How to fix this → Coverage Stats💛 - Coveralls |
AlCalzone
marked this pull request as draft
July 11, 2026 21:11
27 tasks
AlCalzone
force-pushed
the
alcalzone-backend-refactor-hass-domain
branch
from
July 16, 2026 06:51
d0a95f8 to
48c2e1b
Compare
AlCalzone
force-pushed
the
alcalzone-backend-refactor-hass-lifecycle
branch
from
July 16, 2026 10:27
16da52b to
7b7c46f
Compare
AlCalzone
force-pushed
the
alcalzone-backend-refactor-hass-domain
branch
from
July 16, 2026 13:01
48c2e1b to
d8adedf
Compare
AlCalzone
force-pushed
the
alcalzone-backend-refactor-hass-lifecycle
branch
from
July 16, 2026 13:33
7b7c46f to
0209976
Compare
AlCalzone
force-pushed
the
alcalzone-backend-refactor-hass-domain
branch
from
July 16, 2026 19:23
d8adedf to
7d7c6af
Compare
AlCalzone
force-pushed
the
alcalzone-backend-refactor-hass-lifecycle
branch
from
July 16, 2026 19:48
0209976 to
331f854
Compare
AlCalzone
force-pushed
the
alcalzone-backend-refactor-hass-domain
branch
from
July 17, 2026 08:49
7d7c6af to
1ca0240
Compare
…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>
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
force-pushed
the
alcalzone-backend-refactor-hass-lifecycle
branch
from
July 17, 2026 09:23
331f854 to
68413f5
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds lifecycle owners for legacy MQTT discovery and
@zwave-js/server.Gateway,ZwaveClient, and plugin-facing facades.Part of #4722. Stacked on #4729.