docs(offline-setup): what PR #303 measures, and what a working version needs

PR #303 loads the entry when the first poll fails. Measured on its
branch, that produces an entry with zero bound entities and zero
coordinator listeners, so DataUpdateCoordinator never reschedules and
the device never recovers without a manual reload.

Record why entities can't be created offline here (discovery is the only
source of `bound`, and platforms enumerate it once), what a working
version would need (persisted discovery snapshot, reconcile-on-reconnect,
a listener that keeps polling alive), and the cheaper retry-and-reload
option that solves the filed issue on its own.
This commit is contained in:
Marc Billow
2026-08-15 19:38:28 +00:00
parent 67012c57d7
commit bc03ada208
+149
View File
@@ -0,0 +1,149 @@
# Loading a config entry while the appliance is offline
Issue #295 asks for sub-30s recovery when a powered-off appliance comes back,
instead of HA's `ConfigEntryNotReady` backoff (30s → … → 15 min). PR #303
tries to get there by catching the first-refresh failure in
`async_setup_entry` and loading the entry anyway.
That doesn't work here, and the reason is worth writing down: this
integration has no static entity list. Every entity comes from discovery,
and discovery only happens inside a successful poll.
## What PR #303 produces today
Measured on the PR's branch — set up with `_poll_once` raising, then advance
the clock four summary intervals:
| | |
| --- | --- |
| entry state | `LOADED` |
| `coordinator.bound` | 0 |
| entities in the state machine | 0 |
| registry entries | 1 (the disabled connection-mode sensor) |
| coordinator listeners | 0 |
| `_unsub_refresh` | `None` |
| poll attempts over the next 4 intervals | **0** |
The entry loads and then never polls again. `DataUpdateCoordinator._async_refresh`
reschedules only `if not auth_failed and self._listeners and not
self.hass.is_stopping`; with no bound entities the only unconditional entity
is `LocalThingsConnectionModeSensor`, which is
`entity_registry_enabled_default = False` and so never added and never
subscribes. Nothing reloads the entry either. The device comes back online to
an entry that is permanently empty until a manual reload — strictly worse
than the backoff it replaces, which did recover on its own within 15 minutes.
## Why entities can't just be created offline
Four independent gates, all of which need live device data:
1. `bound` is only ever assigned in `_run_discovery` (`coordinator.py:1081`),
which runs on a poll's `resources` dict.
2. All ten platforms enumerate `coordinator.bound` exactly once, at forward
time (`sensor.py:34` and siblings). Nothing adds entities later — the
invariant is already documented at `coordinator.py:1283-1286`.
3. `_is_included` (`entity.py:39`) returns False whenever `last_resources`
has no rep for the href. Even a fully reconstructed `bound` filters to
nothing while `StateCache` is empty.
4. `LocalThingsEntity` is a bare `CoordinatorEntity` with no `available`
override, no `RestoreEntity`, and no `Store` anywhere in the component. An
entity that did exist offline would be `unavailable` with no state.
The issue cites ESPHome, Shelly, LIFX and WLED as precedent for setup that
never fails. Those integrations can do it because each one has a *persisted
device description* to build entities from — ESPHome keeps its entity list in
`.storage`, Shelly caches device info. The pattern is portable; the mechanism
underneath it is the part PR #303 is missing.
## What a working version needs
Three separable pieces, plus a gating rule.
### 1. A persisted discovery snapshot
Write the discovery result back onto the config entry after each successful
`_run_discovery`. There is already precedent for exactly this shape —
`_persist_identity` (`coordinator.py:973`), `CONF_LEARNED_MODES`,
`CONF_CLOUD_COURSES`.
`BoundEntity` holds live `Capability`/`SamsungEntityDescription` objects, so
it isn't directly serializable, but it is re-derivable: persist
`device_type_name`, the materialized `Subdevice` list (plain str/tuple
fields), and per entity the `(subdevice key, capability, desc.key, instance,
key_override, instance_name)` tuple. Rehydrate by re-resolving through
`resolve_registry` + `CAPABILITIES`.
Persist the *post-`_is_included`* set, so rehydration doesn't need reps, and
flag the coordinator as rehydrated so gate 3 above is skipped for that pass.
### 2. Reconcile on reconnect
The snapshot is a guess about a device we haven't talked to yet. When the
first successful poll lands, `_run_discovery` computes the real set; if it
differs from what was rehydrated, the entry has to reload, because gate 2
means platforms can't add the difference in place. `async_schedule_reload`
covers it (available well below the 2025.1.0 floor in `hacs.json`).
This is the piece that makes the whole thing safe against a firmware update,
a newly-appearing subdevice, or a different appliance at the same IP. PR #303
has no equivalent.
### 3. Keep polling with no listeners
Independent of the above, and worth doing on its own merits: hold one
refresh alive for the entry's lifetime so scheduling never depends on entity
count.
```python
entry.async_on_unload(coordinator.async_add_listener(lambda: None))
```
That alone fixes the measured "never polls again" bug, and covers the case
where every rehydrated entity happens to be registry-disabled.
### Gating rule: only load offline when there's a snapshot
An entry that has never successfully polled has nothing to restore and keeps
raising `ConfigEntryNotReady`. This is what answers the objection in the PR
thread — with a snapshot we *do* have metadata to build a device from, and
without one HA's backoff is still the right behavior. It also leaves room for
the #168-style flows that need to interact with the device during setup: a
device that never completed setup still blocks.
## What this still won't do
Entities will be present and `unavailable` — not showing their last values.
Gate 4 means last-known values require either `RestoreEntity` per platform or
persisting `StateCache`, and both mean asserting state the integration cannot
verify: a washer unplugged for a week would read "Running". HA's convention
is that unreachable means unavailable, and the recorder keeps the history
either way, so long-term statistics and history graphs are unaffected by this
choice.
Worth being explicit about, because it is the gap between what PR #303
promises in the thread ("load their previously recorded states") and what any
correct version can deliver.
## The cheaper alternative
If the goal is only issue #295's — fast recovery — pieces 1 and 2 are
unnecessary. Keep `ConfigEntryNotReady`, and add a retry that probes on a
fixed short interval and calls `async_schedule_reload` on the first success.
No persistence, no reconcile, no new failure modes. The device tile still
reads "Retrying setup" while the appliance is off, which is true, and
recovery drops from up-to-15-min to one probe interval.
The issue's other suggestion — wire zeroconf so the device's own boot
announcement triggers the retry — is a non-starter as things stand: there is
no `zeroconf` or `dhcp` key in `manifest.json` and the config flow is
user-driven only, so HA has no discovery signal for this integration to hang
a retry on. It would first need a confirmed mDNS service on the appliance.
## Recommendation
The cheap alternative solves the filed issue. The three-piece version solves
the goal stated later in the PR thread, at the cost of a new persisted
schema, a reconcile path, and a reload-on-mismatch — and it still leaves
entities unavailable, which is the part that was actually being asked for.
Do the cheap one first; treat offline entity materialization as a separate
change with its own issue.