Merge pull request #374 from mbillow/claude/pr-303-code-review-6mghcs
Load a config entry offline from the last discovery snapshot
This commit is contained in:
@@ -270,6 +270,12 @@ If reconnects become persistent (more than a handful per minute), something's ac
|
|||||||
|
|
||||||
Deregistering a device in SmartThings causes a reset of its network settings as soon as it accesses Samsung's servers, dropping it off Wi-Fi until it's re-onboarded through the SmartThings app. As such, consider keeping devices registered even if egress-blocked, to avoid them resetting upon brief internet access.
|
Deregistering a device in SmartThings causes a reset of its network settings as soon as it accesses Samsung's servers, dropping it off Wi-Fi until it's re-onboarded through the SmartThings app. As such, consider keeping devices registered even if egress-blocked, to avoid them resetting upon brief internet access.
|
||||||
|
|
||||||
|
### Restarting while an appliance is powered off
|
||||||
|
|
||||||
|
If Home Assistant restarts while an appliance is unplugged or switched off at the wall, its device and entities still load — restored from the last successful discovery, showing `unavailable` until the appliance answers again. Automations and dashboards keep referring to entities that exist, and the integration retries in the background, so the device comes back on its own within a poll cycle of being powered on. Entities read `unavailable` rather than their last known values on purpose: the integration can't verify what a disconnected appliance is doing, and recorded history is kept by the recorder either way.
|
||||||
|
|
||||||
|
This only applies to an appliance the integration has reached at least once. A brand-new device that has never answered has nothing to restore from, so setting it up still requires it to be reachable.
|
||||||
|
|
||||||
### Multi-subdevice ("2-in-1") air conditioner systems
|
### Multi-subdevice ("2-in-1") air conditioner systems
|
||||||
|
|
||||||
Some Samsung installs run more than one indoor subdevice off a single outdoor unit, all reachable over the *one* IP/DTLS session your config entry connects to (a floor-standing + wall-mounted 2-in-1 is a common shape). The integration discovers any sibling subdevices automatically, once, right after the first successful poll — there's nothing to configure. Each discovered subdevice gets its own HA device (linked to the main one via "via device") and its own `climate` card, so it lands in its own room in the dashboard instead of being invisible or mixed into the master's state.
|
Some Samsung installs run more than one indoor subdevice off a single outdoor unit, all reachable over the *one* IP/DTLS session your config entry connects to (a floor-standing + wall-mounted 2-in-1 is a common shape). The integration discovers any sibling subdevices automatically, once, right after the first successful poll — there's nothing to configure. Each discovered subdevice gets its own HA device (linked to the main one via "via device") and its own `climate` card, so it lands in its own room in the dashboard instead of being invisible or mixed into the master's state.
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ from homeassistant.helpers import entity_registry as er
|
|||||||
from homeassistant.helpers.typing import ConfigType
|
from homeassistant.helpers.typing import ConfigType
|
||||||
|
|
||||||
from .const import CONF_DEVICE_TYPE, CONF_HOST, CONF_PORT, CONF_SERIAL, DOMAIN, PLATFORMS
|
from .const import CONF_DEVICE_TYPE, CONF_HOST, CONF_PORT, CONF_SERIAL, DOMAIN, PLATFORMS
|
||||||
from .coordinator import LocalThingsCoordinator
|
from .coordinator import LocalThingsCoordinator, snapshot_store
|
||||||
from .registry.identity import resolve_serial
|
from .registry.identity import resolve_serial
|
||||||
from .services import async_setup_services
|
from .services import async_setup_services
|
||||||
|
|
||||||
@@ -260,17 +260,30 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
hass.data.setdefault(DOMAIN, {})
|
hass.data.setdefault(DOMAIN, {})
|
||||||
coordinator = LocalThingsCoordinator(hass, entry)
|
coordinator = LocalThingsCoordinator(hass, entry)
|
||||||
|
|
||||||
|
# Before the first refresh, so the coordinator keeps rescheduling even
|
||||||
|
# when that refresh fails and leaves nothing subscribed: the base class
|
||||||
|
# only re-arms its timer while it has listeners, and an offline load can
|
||||||
|
# legitimately have zero live entities (every one of them disabled, say).
|
||||||
|
# Without this the entry loads once and never polls again (issue #295).
|
||||||
|
entry.async_on_unload(coordinator.async_add_listener(lambda: None))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await coordinator.async_config_entry_first_refresh()
|
await coordinator.async_config_entry_first_refresh()
|
||||||
except Exception as err:
|
except ConfigEntryNotReady:
|
||||||
# `_poll_once` deliberately leaves the session up on a TimeoutError
|
# An entry that has polled successfully before comes up on its last
|
||||||
# (see its docstring), so a refresh failing that way leaves a live,
|
# known entity set and keeps retrying on the normal poll interval,
|
||||||
# bound UDP socket nothing would ever close. HA retries setup with a
|
# rather than sitting in setup-retry with a device that reads as
|
||||||
# new coordinator, and the source port is fixed by design
|
# broken and entities that exist only as registry rows (issue #295).
|
||||||
# (`_local_source_port`), so an abandoned socket would squat the
|
#
|
||||||
# exact port the next attempt binds.
|
# An entry that has never reached the device has no snapshot, so
|
||||||
await coordinator.async_close()
|
# there is nothing to show and no device metadata to name it with --
|
||||||
raise ConfigEntryNotReady(f"Cannot connect to device: {err}") from err
|
# that case still fails, which is also what keeps the door open for
|
||||||
|
# setup flows that need to interact with the device (issue #168).
|
||||||
|
if not await coordinator.async_rehydrate():
|
||||||
|
await coordinator.async_close()
|
||||||
|
raise
|
||||||
|
|
||||||
hass.data[DOMAIN][entry.entry_id] = coordinator
|
hass.data[DOMAIN][entry.entry_id] = coordinator
|
||||||
|
|
||||||
# Send the DTLS close_notify on Core shutdown, not just on unload (issue
|
# Send the DTLS close_notify on Core shutdown, not just on unload (issue
|
||||||
@@ -327,6 +340,16 @@ async def async_remove_config_entry_device(
|
|||||||
return not (device.identifiers & live)
|
return not (device.identifiers & live)
|
||||||
|
|
||||||
|
|
||||||
|
async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||||
|
"""Delete the discovery snapshot this entry accumulated (issue #295).
|
||||||
|
|
||||||
|
Nothing else would: the store is keyed on entry_id, so re-adding the same
|
||||||
|
appliance mints a new one and the old file would linger in .storage
|
||||||
|
forever.
|
||||||
|
"""
|
||||||
|
await snapshot_store(hass, entry).async_remove()
|
||||||
|
|
||||||
|
|
||||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
unloaded = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
unloaded = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||||
if unloaded:
|
if unloaded:
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import logging
|
|||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
import zlib
|
import zlib
|
||||||
|
from dataclasses import asdict
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from typing import Any, cast
|
from typing import Any, cast
|
||||||
|
|
||||||
@@ -18,6 +19,7 @@ from homeassistant.core import HomeAssistant, callback
|
|||||||
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||||
from homeassistant.helpers import issue_registry as ir
|
from homeassistant.helpers import issue_registry as ir
|
||||||
from homeassistant.helpers.device_registry import DeviceInfo
|
from homeassistant.helpers.device_registry import DeviceInfo
|
||||||
|
from homeassistant.helpers.storage import Store
|
||||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||||
from smartthings_local.ocf.state_cache import StateCache
|
from smartthings_local.ocf.state_cache import StateCache
|
||||||
from smartthings_local.protocol.dtls_session import DtlsCoapSession
|
from smartthings_local.protocol.dtls_session import DtlsCoapSession
|
||||||
@@ -47,7 +49,7 @@ from .const import (
|
|||||||
from .learned import LEARNABLE, LearnedModes, persist
|
from .learned import LEARNABLE, LearnedModes, persist
|
||||||
from .observe import GRACE_PERIOD_S, MODE_OBSERVE, MODE_POLL, ObserveManager
|
from .observe import GRACE_PERIOD_S, MODE_OBSERVE, MODE_POLL, ObserveManager
|
||||||
from .registry import CAPABILITIES
|
from .registry import CAPABILITIES
|
||||||
from .registry.adapter import flatten
|
from .registry.adapter import _key, flatten
|
||||||
from .registry.batch import parse_device0_batch
|
from .registry.batch import parse_device0_batch
|
||||||
from .registry.by_type import resolve as resolve_registry
|
from .registry.by_type import resolve as resolve_registry
|
||||||
from .registry.capabilities.common import (
|
from .registry.capabilities.common import (
|
||||||
@@ -83,6 +85,20 @@ _LOGGER = logging.getLogger(__name__)
|
|||||||
|
|
||||||
_SEED_PATH = ["device", "0"]
|
_SEED_PATH = ["device", "0"]
|
||||||
|
|
||||||
|
# Discovery snapshot (issue #295): exactly what the last successful first
|
||||||
|
# cycle fed _run_discovery, so a restart can register the same entities
|
||||||
|
# while the appliance is unreachable. Kept in .storage rather than on the
|
||||||
|
# config entry -- it's device state, not configuration, and runs to tens of
|
||||||
|
# kilobytes.
|
||||||
|
_SNAPSHOT_VERSION = 1
|
||||||
|
|
||||||
|
|
||||||
|
def snapshot_store(hass: HomeAssistant, entry: ConfigEntry) -> Store[dict[str, Any]]:
|
||||||
|
"""This entry's discovery-snapshot store. A free function so
|
||||||
|
`async_remove_entry` can delete the file without standing up a whole
|
||||||
|
coordinator to reach it."""
|
||||||
|
return Store(hass, _SNAPSHOT_VERSION, f"{DOMAIN}.{entry.entry_id}.discovery")
|
||||||
|
|
||||||
|
|
||||||
class _NoOpDescriptor:
|
class _NoOpDescriptor:
|
||||||
"""No-op: StateCache requires an on_observation hook; this integration
|
"""No-op: StateCache requires an on_observation hook; this integration
|
||||||
@@ -248,6 +264,11 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
self._identity: DeviceIdentity | None = None
|
self._identity: DeviceIdentity | None = None
|
||||||
self._discovered = False
|
self._discovered = False
|
||||||
self.bound = []
|
self.bound = []
|
||||||
|
self._snapshot_store = snapshot_store(hass, entry)
|
||||||
|
# Both set only when this entry loaded from a snapshot instead of a
|
||||||
|
# live poll -- see async_rehydrate.
|
||||||
|
self._rehydrate_resources: dict[str, dict] | None = None
|
||||||
|
self._rehydrated_keys: frozenset[tuple[str, str]] | None = None
|
||||||
# Sibling indoor subdevices on this connection (issue #177); set
|
# Sibling indoor subdevices on this connection (issue #177); set
|
||||||
# once at first discovery, narrowed to the ones with live state (see
|
# once at first discovery, narrowed to the ones with live state (see
|
||||||
# subdevices.discover_partitioned). Never includes MAIN itself.
|
# subdevices.discover_partitioned). Never includes MAIN itself.
|
||||||
@@ -405,6 +426,35 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
self._canonical_cache[view_key] = view
|
self._canonical_cache[view_key] = view
|
||||||
return view
|
return view
|
||||||
|
|
||||||
|
@property
|
||||||
|
def rehydrated(self) -> bool:
|
||||||
|
"""True while this entry's entities came from a snapshot rather than
|
||||||
|
a live poll (issue #295)."""
|
||||||
|
return self._rehydrate_resources is not None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def discovery_resources(self) -> dict[str, dict]:
|
||||||
|
"""What entity._is_included should judge an entity's existence
|
||||||
|
against: the rehydration snapshot on an offline load, the live cache
|
||||||
|
otherwise.
|
||||||
|
|
||||||
|
Deliberately separate from `last_resources`, which stays empty until
|
||||||
|
the device answers -- that emptiness is what keeps a rehydrated
|
||||||
|
entity `unavailable` instead of rendering a snapshot's stale value.
|
||||||
|
Only read while platforms are being forwarded; nothing consults it
|
||||||
|
once the entities exist.
|
||||||
|
"""
|
||||||
|
if self._rehydrate_resources is None:
|
||||||
|
return self.last_resources
|
||||||
|
return self._rehydrate_resources
|
||||||
|
|
||||||
|
def discovery_canonical(self, subdevice: Subdevice) -> dict[str, dict]:
|
||||||
|
"""`discovery_resources` in `subdevice`'s canonical view -- the
|
||||||
|
exists_fn counterpart to canonical_resources."""
|
||||||
|
if self._rehydrate_resources is None:
|
||||||
|
return self.canonical_resources(subdevice)
|
||||||
|
return canonical_view(subdevice, self._rehydrate_resources, self.subdevices)
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Learned modes (issue #327)
|
# Learned modes (issue #327)
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -999,7 +1049,148 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
self._entry, data={**self._entry.data, **identity}
|
self._entry, data={**self._entry.data, **identity}
|
||||||
)
|
)
|
||||||
|
|
||||||
def _run_discovery(self, resources: dict[str, dict]) -> None:
|
# ------------------------------------------------------------------
|
||||||
|
# Discovery snapshot (issue #295)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _bound_keys(self) -> frozenset[tuple[str, str]]:
|
||||||
|
"""This entity set's identity: one (subdevice, state key) pair per
|
||||||
|
bound entity. `_key` is the unique_id suffix, so two discoveries that
|
||||||
|
agree here would register byte-identical entities."""
|
||||||
|
return frozenset((b.subdevice.key, _key(b)) for b in self.bound)
|
||||||
|
|
||||||
|
async def _async_save_snapshot(
|
||||||
|
self, resources: dict[str, dict], candidates: list[Subdevice]
|
||||||
|
) -> None:
|
||||||
|
"""Record what this first cycle handed `_run_discovery`, so the next
|
||||||
|
restart can replay it while the appliance is unreachable.
|
||||||
|
|
||||||
|
`candidates` is the pre-narrowing subdevice list (issue #177):
|
||||||
|
`discover_partitioned` takes candidates and returns the live ones, so
|
||||||
|
replaying against the narrowed list would rediscover nothing for a
|
||||||
|
composite appliance's siblings.
|
||||||
|
|
||||||
|
Written now rather than through `async_delay_save`, because a pending
|
||||||
|
delayed write outlives whatever queued it: it lands after
|
||||||
|
`async_remove_entry` has deleted the file and recreates it orphaned,
|
||||||
|
and a reload scheduled by `_reconcile_rehydrated` would read the
|
||||||
|
pre-reload snapshot back off disk. This runs once per entry load, so
|
||||||
|
the immediate write costs nothing worth deferring.
|
||||||
|
"""
|
||||||
|
ident = self._identity
|
||||||
|
try:
|
||||||
|
await self._snapshot_store.async_save(
|
||||||
|
{
|
||||||
|
"resources": dict(resources),
|
||||||
|
"subdevice_candidates": [asdict(su) for su in candidates],
|
||||||
|
"identity": asdict(ident) if ident is not None else None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
# Never fail a poll over the snapshot -- a board reporting
|
||||||
|
# something the JSON encoder rejects would otherwise break
|
||||||
|
# polling outright. Worst case this entry can't load offline,
|
||||||
|
# which is where it was before any of this existed.
|
||||||
|
self._log.warning("could not write discovery snapshot: %s", e)
|
||||||
|
|
||||||
|
async def async_rehydrate(self) -> bool:
|
||||||
|
"""Register the last known entity set without reaching the device.
|
||||||
|
|
||||||
|
Replays the stored snapshot through `_run_discovery`, which is what
|
||||||
|
makes this faithful: same code path, same registry resolution, so an
|
||||||
|
offline load produces the entity set the device last actually
|
||||||
|
reported rather than a guess reconstructed from a parallel format.
|
||||||
|
|
||||||
|
Returns False when there's nothing stored (an entry that has never
|
||||||
|
polled successfully) or the replay produced nothing usable -- the
|
||||||
|
caller raises ConfigEntryNotReady in that case, exactly as before.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
stored = await self._snapshot_store.async_load()
|
||||||
|
except Exception as e: # corrupt or unreadable store
|
||||||
|
self._log.warning("could not read discovery snapshot: %s", e)
|
||||||
|
return False
|
||||||
|
if not stored or not stored.get("resources"):
|
||||||
|
return False
|
||||||
|
|
||||||
|
resources = stored["resources"]
|
||||||
|
try:
|
||||||
|
ident = stored.get("identity")
|
||||||
|
if ident is not None:
|
||||||
|
self._identity = DeviceIdentity(
|
||||||
|
manufacturer=ident.get("manufacturer") or "",
|
||||||
|
model=ident.get("model") or "",
|
||||||
|
name=ident.get("name") or "",
|
||||||
|
serial=ident.get("serial"),
|
||||||
|
device_types=tuple(ident.get("device_types") or ()),
|
||||||
|
raw=ident.get("raw") or {},
|
||||||
|
)
|
||||||
|
# JSON gives lists back where Subdevice declares tuples, and it's
|
||||||
|
# a frozen (hashable) dataclass used as a dict key in flatten().
|
||||||
|
self.subdevices = [
|
||||||
|
Subdevice(
|
||||||
|
kind=su["kind"],
|
||||||
|
key=su["key"],
|
||||||
|
seed_path=tuple(su.get("seed_path") or ()),
|
||||||
|
flat_hrefs=tuple(su.get("flat_hrefs") or ()),
|
||||||
|
)
|
||||||
|
for su in stored.get("subdevice_candidates") or ()
|
||||||
|
]
|
||||||
|
self._run_discovery(resources, from_snapshot=True)
|
||||||
|
except Exception as e:
|
||||||
|
# A snapshot written by an older release can outlive both the
|
||||||
|
# stored shape and the registry it was discovered against. This
|
||||||
|
# has to catch the rebuild as well as the replay: an exception
|
||||||
|
# escaping here reaches async_setup_entry, which only handles
|
||||||
|
# ConfigEntryNotReady, so the entry would land in SETUP_ERROR --
|
||||||
|
# never retried, and with its session left open.
|
||||||
|
self._log.warning("discovery snapshot could not be replayed: %s", e, exc_info=True)
|
||||||
|
self.bound = []
|
||||||
|
self.subdevices = []
|
||||||
|
return False
|
||||||
|
|
||||||
|
# _run_discovery sets _discovered; put it back. The snapshot only
|
||||||
|
# supplied an entity set to register -- the first live poll must
|
||||||
|
# still enumerate subdevices and rediscover for real.
|
||||||
|
self._discovered = False
|
||||||
|
self._rehydrate_resources = resources
|
||||||
|
self._rehydrated_keys = self._bound_keys()
|
||||||
|
if not self.bound:
|
||||||
|
return False
|
||||||
|
self._log.info(
|
||||||
|
"device unreachable; restored %d entities from the last discovery "
|
||||||
|
"snapshot and will keep retrying every %ds",
|
||||||
|
len(self.bound),
|
||||||
|
SUMMARY_INTERVAL_S,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def _reconcile_rehydrated(self) -> None:
|
||||||
|
"""Reload the entry when a live discovery disagrees with the snapshot
|
||||||
|
this load registered from.
|
||||||
|
|
||||||
|
Platforms enumerate `bound` exactly once, at forward time, so a
|
||||||
|
firmware update, a newly-answering sibling subdevice or a different
|
||||||
|
appliance at the same IP can't be picked up in place -- the entry has
|
||||||
|
to come back up against the live set.
|
||||||
|
"""
|
||||||
|
if self._rehydrated_keys is None:
|
||||||
|
return
|
||||||
|
stale = self._rehydrated_keys
|
||||||
|
self._rehydrated_keys = None
|
||||||
|
live = self._bound_keys()
|
||||||
|
if live == stale:
|
||||||
|
return
|
||||||
|
self._log.info(
|
||||||
|
"live discovery differs from the snapshot this entry loaded from "
|
||||||
|
"(%d entities gone, %d new); reloading",
|
||||||
|
len(stale - live),
|
||||||
|
len(live - stale),
|
||||||
|
)
|
||||||
|
self.hass.config_entries.async_schedule_reload(self._entry.entry_id)
|
||||||
|
|
||||||
|
def _run_discovery(self, resources: dict[str, dict], from_snapshot: bool = False) -> None:
|
||||||
# Diagnostics only -- names the firmware generation (e.g. '7.0 Air
|
# Diagnostics only -- names the firmware generation (e.g. '7.0 Air
|
||||||
# conditioner' is Tizen Lite); doesn't route, since every device
|
# conditioner' is Tizen Lite); doesn't route, since every device
|
||||||
# that reports it is already typed by modelNum.
|
# that reports it is already typed by modelNum.
|
||||||
@@ -1115,7 +1306,13 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
model=model,
|
model=model,
|
||||||
)
|
)
|
||||||
self._persist_identity(serial, model, mfr, device_type_name)
|
self._persist_identity(serial, model, mfr, device_type_name)
|
||||||
self._update_coverage_gap_issue(device_type_name is None, unbound, name)
|
if not from_snapshot:
|
||||||
|
# A coverage gap is a claim about what the device reports, so only
|
||||||
|
# a live poll gets to make it. Replaying a snapshot would restate
|
||||||
|
# last run's conclusion while pointing the user at a diagnostics
|
||||||
|
# download that is empty until the appliance answers, and any
|
||||||
|
# drift in the device name between the two would churn the issue.
|
||||||
|
self._update_coverage_gap_issue(device_type_name is None, unbound, name)
|
||||||
|
|
||||||
self._hot_hrefs = sorted(hot)
|
self._hot_hrefs = sorted(hot)
|
||||||
self._warm_hrefs = sorted(warm)
|
self._warm_hrefs = sorted(warm)
|
||||||
@@ -1422,7 +1619,13 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
# StateCache has no eviction, so the only way to keep them out
|
# StateCache has no eviction, so the only way to keep them out
|
||||||
# is to not put them in. Safe to reorder: _run_discovery reads
|
# is to not put them in. Safe to reorder: _run_discovery reads
|
||||||
# the passed dict, never the cache.
|
# the passed dict, never the cache.
|
||||||
|
candidates = list(self.subdevices)
|
||||||
self._run_discovery(resources)
|
self._run_discovery(resources)
|
||||||
|
# Banked before the reconcile below, so a reload it schedules
|
||||||
|
# comes up against this discovery rather than the one that is
|
||||||
|
# being replaced.
|
||||||
|
await self._async_save_snapshot(resources, candidates)
|
||||||
|
self._reconcile_rehydrated()
|
||||||
resources = self._live_subdevice_resources(resources)
|
resources = self._live_subdevice_resources(resources)
|
||||||
sweep_mismatch = False
|
sweep_mismatch = False
|
||||||
if self._observe.mode == MODE_OBSERVE:
|
if self._observe.mode == MODE_OBSERVE:
|
||||||
|
|||||||
@@ -35,12 +35,16 @@ def _is_included(bound: BoundEntity, coordinator: LocalThingsCoordinator) -> boo
|
|||||||
`bound`'s own subdevice's canonical view instead of the raw snapshot,
|
`bound`'s own subdevice's canonical view instead of the raw snapshot,
|
||||||
same rule as everywhere else a whole-resources-dict scan happens --
|
same rule as everywhere else a whole-resources-dict scan happens --
|
||||||
this is a free function, so it can't use self._resources.
|
this is a free function, so it can't use self._resources.
|
||||||
|
|
||||||
|
Reads `discovery_resources`, not `last_resources`: on an offline load
|
||||||
|
(issue #295) the live cache is still empty, and judging existence
|
||||||
|
against it would filter every rehydrated entity away.
|
||||||
"""
|
"""
|
||||||
rep = coordinator.last_resources.get(bound.href)
|
rep = coordinator.discovery_resources.get(bound.href)
|
||||||
if rep is None:
|
if rep is None:
|
||||||
return False
|
return False
|
||||||
if bound.desc.exists_fn is not None:
|
if bound.desc.exists_fn is not None:
|
||||||
return bound.desc.exists_fn(rep, coordinator.canonical_resources(bound.subdevice))
|
return bound.desc.exists_fn(rep, coordinator.discovery_canonical(bound.subdevice))
|
||||||
if bound.desc.field:
|
if bound.desc.field:
|
||||||
if not rep or is_stub_rep(rep):
|
if not rep or is_stub_rep(rep):
|
||||||
return True
|
return True
|
||||||
|
|||||||
@@ -0,0 +1,187 @@
|
|||||||
|
# Loading a config entry while the appliance is offline
|
||||||
|
|
||||||
|
Issue #295 asks for faster recovery when a powered-off appliance comes back,
|
||||||
|
instead of waiting out HA's `ConfigEntryNotReady` backoff. PR #303 tried 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.
|
||||||
|
|
||||||
|
(The issue's "up to 15 minutes" is out of date, incidentally. Current HA
|
||||||
|
retries on `2 ** min(tries, 4) * 5` seconds — capped at 80s, not 900. The
|
||||||
|
backoff was never the worst part; a device card reading "Retrying setup" with
|
||||||
|
no entities behind it is.)
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
## How the implemented version works
|
||||||
|
|
||||||
|
Three pieces, plus a gating rule.
|
||||||
|
|
||||||
|
### 1. A persisted discovery snapshot
|
||||||
|
|
||||||
|
After each successful first cycle, `_save_snapshot` banks exactly the
|
||||||
|
`resources` dict that cycle handed `_run_discovery`, along with the
|
||||||
|
pre-narrowing subdevice candidate list and the `DeviceIdentity` read from
|
||||||
|
`/oic/*`.
|
||||||
|
|
||||||
|
Storing the poll input rather than a rendered entity list is the decision
|
||||||
|
that keeps this honest. `BoundEntity` holds live
|
||||||
|
`Capability`/`SamsungEntityDescription` objects and isn't serializable, so
|
||||||
|
the alternative was a parallel format plus a re-resolution path — a second
|
||||||
|
implementation of discovery that could drift from the real one. Replaying the
|
||||||
|
input through `_run_discovery` means the same code, the same registry
|
||||||
|
resolution, and no second source of truth.
|
||||||
|
|
||||||
|
Three things ride along because `_run_discovery` reads them off `self`
|
||||||
|
rather than out of `resources`, and getting them wrong would silently resolve
|
||||||
|
a *different* registry offline than online — which reconciliation below would
|
||||||
|
then see as a real change and reload on every restart:
|
||||||
|
|
||||||
|
- `_identity.device_types` routes `resolve_registry`.
|
||||||
|
- `self.subdevices` is the candidate list `discover_partitioned` narrows;
|
||||||
|
replaying against the already-narrowed list finds no siblings at all.
|
||||||
|
- `_identity.manufacturer`/`model` feed `device_info`.
|
||||||
|
|
||||||
|
It lives in `.storage` (`Store`, keyed on entry_id) rather than on the config
|
||||||
|
entry: it's device state, not configuration, and runs to tens of kilobytes.
|
||||||
|
`async_remove_entry` deletes it with the entry.
|
||||||
|
|
||||||
|
The write is awaited, not `async_delay_save`d. A deferred write outlives
|
||||||
|
whatever queued it: it lands after `async_remove_entry` has deleted the file
|
||||||
|
and recreates it orphaned, and a reload scheduled by the reconcile below
|
||||||
|
would read the pre-reload snapshot back off disk. It runs once per entry
|
||||||
|
load, so there's nothing worth deferring. A write that fails is logged and
|
||||||
|
swallowed — a board reporting something the JSON encoder rejects must not
|
||||||
|
break polling.
|
||||||
|
|
||||||
|
### 2. Reconcile on reconnect
|
||||||
|
|
||||||
|
The snapshot is a claim about a device we haven't talked to yet. When the
|
||||||
|
first live poll lands, `_reconcile_rehydrated` compares the live entity set
|
||||||
|
against the rehydrated one — as `(subdevice key, _key(bound))` pairs, which
|
||||||
|
is the unique_id identity — and calls `async_schedule_reload` if they differ.
|
||||||
|
|
||||||
|
Gate 2 above is why this has to be a reload rather than an in-place fixup.
|
||||||
|
It's what makes the feature safe against a firmware update, a sibling
|
||||||
|
subdevice that starts answering, or a different appliance at the same IP.
|
||||||
|
|
||||||
|
### 3. Keep polling with no listeners
|
||||||
|
|
||||||
|
`async_setup_entry` holds one listener for the entry's lifetime:
|
||||||
|
|
||||||
|
```python
|
||||||
|
entry.async_on_unload(coordinator.async_add_listener(lambda: None))
|
||||||
|
```
|
||||||
|
|
||||||
|
Registered *before* the first refresh, so scheduling survives a refresh that
|
||||||
|
fails. This alone fixes the measured "never polls again" bug, and covers a
|
||||||
|
rehydrated set whose entities are all registry-disabled. Removing the last
|
||||||
|
listener unschedules the timer, and HA runs `async_on_unload` callbacks when
|
||||||
|
setup raises, so the setup-retry path doesn't leak a polling coordinator.
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
|
It also means `async_remove_config_entry_device` is no longer reachable with
|
||||||
|
an empty `coordinator.subdevices`, so an offline load can't offer to delete a
|
||||||
|
real-but-unreachable subdevice.
|
||||||
|
|
||||||
|
### The coverage-gap Repair stays live-only
|
||||||
|
|
||||||
|
`_run_discovery(..., from_snapshot=True)` skips `_update_coverage_gap_issue`.
|
||||||
|
The Repair points the user at a diagnostics download, which is empty until
|
||||||
|
the appliance answers, and a device name that drifts between the snapshot and
|
||||||
|
the live poll would churn the issue for no reason.
|
||||||
|
|
||||||
|
Not a de-duplication measure — HA already handles that. `async_create_issue`
|
||||||
|
is keyed on `(domain, issue_id)`, `dataclasses.replace` in
|
||||||
|
`async_get_or_create` leaves `dismissed_version` alone, and the registry
|
||||||
|
reloads non-persistent issues with their dismissal intact, so one row per
|
||||||
|
entry survives restarts and an "Ignore" sticks.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
## Rejected: zeroconf
|
||||||
|
|
||||||
|
The issue's other suggestion — wire zeroconf so the device's own boot
|
||||||
|
announcement triggers a retry, which is the genuinely idiomatic HA answer —
|
||||||
|
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. Worth revisiting if one turns up;
|
||||||
|
it would make recovery near-instant instead of within one poll interval.
|
||||||
|
|
||||||
|
## Rejected: the cheap version
|
||||||
|
|
||||||
|
Keeping `ConfigEntryNotReady` and adding a probe that calls
|
||||||
|
`async_schedule_reload` on first success would have fixed the recovery *time*
|
||||||
|
in about twenty lines, with no persistence and no reconcile. It was rejected
|
||||||
|
because it leaves the device reading as broken for as long as the appliance
|
||||||
|
is off, which is the half of issue #295 that actually bites — an appliance
|
||||||
|
switched off at the wall is offline for days, not seconds, and a whole
|
||||||
|
integration that looks failed for that entire window is the complaint.
|
||||||
@@ -71,7 +71,12 @@ async def test_summary_interval(hass: HomeAssistant, mock_entry, mock_coordinato
|
|||||||
|
|
||||||
|
|
||||||
async def test_update_failed_on_persistent_poll_error(hass: HomeAssistant, mock_entry) -> None:
|
async def test_update_failed_on_persistent_poll_error(hass: HomeAssistant, mock_entry) -> None:
|
||||||
"""ConfigEntryNotReady raised when poll fails even after reconnect."""
|
"""ConfigEntryNotReady raised when poll fails even after reconnect.
|
||||||
|
|
||||||
|
An entry with no stored discovery snapshot has never reached this device,
|
||||||
|
so there is nothing to load offline from (issue #295) -- it stays on HA's
|
||||||
|
backoff rather than loading empty.
|
||||||
|
"""
|
||||||
|
|
||||||
with (
|
with (
|
||||||
patch("custom_components.localthings.coordinator.LocalThingsCoordinator._connect_session"),
|
patch("custom_components.localthings.coordinator.LocalThingsCoordinator._connect_session"),
|
||||||
@@ -1824,10 +1829,11 @@ async def test_first_refresh_timeout_recovers_via_reconnect(
|
|||||||
async def test_first_refresh_persistent_timeout_fails_setup(
|
async def test_first_refresh_persistent_timeout_fails_setup(
|
||||||
hass: HomeAssistant, mock_entry
|
hass: HomeAssistant, mock_entry
|
||||||
) -> None:
|
) -> None:
|
||||||
"""When the reconnect times out too, the first refresh must fail so HA
|
"""With no snapshot to load from, a reconnect that times out too must
|
||||||
retries on its backoff -- not load an entity-less entry. The session it
|
fail the first refresh so HA retries on its backoff -- not load an
|
||||||
left open is closed on the way out (`_poll_once` keeps it up on a
|
entity-less entry. The session it left open is closed on the way out
|
||||||
`TimeoutError`, and the source port is fixed per device)."""
|
(`_poll_once` keeps it up on a `TimeoutError`, and the source port is
|
||||||
|
fixed per device)."""
|
||||||
with (
|
with (
|
||||||
patch("custom_components.localthings.coordinator.LocalThingsCoordinator._connect_session"),
|
patch("custom_components.localthings.coordinator.LocalThingsCoordinator._connect_session"),
|
||||||
patch(
|
patch(
|
||||||
|
|||||||
@@ -0,0 +1,418 @@
|
|||||||
|
"""Loading a config entry while the appliance is unreachable (issue #295).
|
||||||
|
|
||||||
|
The device's entity set only exists as the output of a live poll, so coming
|
||||||
|
up offline means replaying the last successful discovery from a stored
|
||||||
|
snapshot. These tests pin the four things that makes load-bearing: the
|
||||||
|
snapshot gets written, it produces the same entity set offline, the
|
||||||
|
coordinator keeps polling until the device answers, and a live discovery that
|
||||||
|
disagrees with the snapshot reloads the entry rather than silently keeping a
|
||||||
|
stale set.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from datetime import timedelta
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from homeassistant.config_entries import ConfigEntryState
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers import issue_registry as ir
|
||||||
|
from homeassistant.util import dt as dt_util
|
||||||
|
from pytest_homeassistant_custom_component.common import async_fire_time_changed
|
||||||
|
|
||||||
|
from custom_components.localthings.const import DOMAIN, SUMMARY_INTERVAL_S
|
||||||
|
from custom_components.localthings.coordinator import LocalThingsCoordinator
|
||||||
|
from custom_components.localthings.registry.identity import DeviceIdentity
|
||||||
|
|
||||||
|
from .conftest import _load_fridge_resources as _load_fridge
|
||||||
|
|
||||||
|
_COORD = "custom_components.localthings.coordinator.LocalThingsCoordinator"
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _reachable(resources: dict, identity: DeviceIdentity | None = None):
|
||||||
|
"""A device that answers, optionally with an /oic/* identity -- which
|
||||||
|
`_connect_session` is what normally reads, so a test that patches it out
|
||||||
|
otherwise leaves `_identity` None."""
|
||||||
|
|
||||||
|
def _connect(self) -> None:
|
||||||
|
self._identity = identity
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(f"{_COORD}._connect_session", _connect),
|
||||||
|
patch(f"{_COORD}._poll_once", return_value=resources),
|
||||||
|
patch(f"{_COORD}._close_session"),
|
||||||
|
):
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _unreachable():
|
||||||
|
with (
|
||||||
|
patch(f"{_COORD}._connect_session"),
|
||||||
|
patch(f"{_COORD}._poll_once", side_effect=OSError("device offline")),
|
||||||
|
patch(f"{_COORD}._close_session"),
|
||||||
|
):
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
def _store_key(entry) -> str:
|
||||||
|
return f"{DOMAIN}.{entry.entry_id}.discovery"
|
||||||
|
|
||||||
|
|
||||||
|
async def _tick(hass: HomeAssistant) -> None:
|
||||||
|
"""Advance past one summary interval so the coordinator polls again.
|
||||||
|
|
||||||
|
`wait_background_tasks` is load-bearing: DataUpdateCoordinator runs its
|
||||||
|
interval refresh as a background task, which a plain block_till_done
|
||||||
|
doesn't await -- the poll would still be in flight at the assertion.
|
||||||
|
"""
|
||||||
|
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=SUMMARY_INTERVAL_S + 1))
|
||||||
|
await hass.async_block_till_done(wait_background_tasks=True)
|
||||||
|
|
||||||
|
|
||||||
|
async def _setup_online_then_unload(hass: HomeAssistant, entry, resources: dict) -> set[str]:
|
||||||
|
"""Bring the entry up against a live device, bank the snapshot, and take
|
||||||
|
it back down. Returns the entity_ids that run produced."""
|
||||||
|
with _reachable(resources):
|
||||||
|
await hass.config_entries.async_setup(entry.entry_id)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
entity_ids = {s.entity_id for s in hass.states.async_all()}
|
||||||
|
await hass.config_entries.async_unload(entry.entry_id)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
return entity_ids
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Writing the snapshot
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def test_snapshot_written_after_first_discovery(
|
||||||
|
hass: HomeAssistant, mock_entry, mock_coordinator_session, hass_storage
|
||||||
|
) -> None:
|
||||||
|
"""A successful first cycle banks what it handed _run_discovery."""
|
||||||
|
await hass.config_entries.async_setup(mock_entry.entry_id)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
stored = hass_storage[_store_key(mock_entry)]["data"]
|
||||||
|
assert stored["resources"]
|
||||||
|
assert "/information/vs/0" in stored["resources"]
|
||||||
|
assert "subdevice_candidates" in stored
|
||||||
|
|
||||||
|
|
||||||
|
async def test_snapshot_not_written_when_device_never_answers(
|
||||||
|
hass: HomeAssistant, mock_entry, hass_storage
|
||||||
|
) -> None:
|
||||||
|
"""Nothing to bank, so nothing is -- this is what keeps the no-snapshot
|
||||||
|
gate meaningful on a brand-new entry."""
|
||||||
|
with _unreachable():
|
||||||
|
await hass.config_entries.async_setup(mock_entry.entry_id)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
assert mock_entry.state is ConfigEntryState.SETUP_RETRY
|
||||||
|
|
||||||
|
assert _store_key(mock_entry) not in hass_storage
|
||||||
|
|
||||||
|
|
||||||
|
async def test_snapshot_removed_when_entry_removed(
|
||||||
|
hass: HomeAssistant, mock_entry, mock_coordinator_session, hass_storage
|
||||||
|
) -> None:
|
||||||
|
"""The store is keyed on entry_id, so re-adding the appliance mints a new
|
||||||
|
one -- the old file has to go with the entry that wrote it.
|
||||||
|
|
||||||
|
The clock is run on afterwards because a deferred write would land here:
|
||||||
|
with `async_delay_save` the removal was undone a few seconds later by the
|
||||||
|
save the last poll had queued, leaving the file orphaned for good.
|
||||||
|
"""
|
||||||
|
await hass.config_entries.async_setup(mock_entry.entry_id)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
assert _store_key(mock_entry) in hass_storage
|
||||||
|
|
||||||
|
await hass.config_entries.async_remove(mock_entry.entry_id)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
assert hass_storage.get(_store_key(mock_entry), {}).get("data") is None
|
||||||
|
|
||||||
|
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=60))
|
||||||
|
await hass.async_block_till_done(wait_background_tasks=True)
|
||||||
|
|
||||||
|
assert hass_storage.get(_store_key(mock_entry), {}).get("data") is None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Loading from it
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def test_offline_load_restores_the_same_entity_set(
|
||||||
|
hass: HomeAssistant, mock_entry, hass_storage
|
||||||
|
) -> None:
|
||||||
|
"""The whole point: a restart with the appliance powered off comes up on
|
||||||
|
the entity set the device last actually reported."""
|
||||||
|
resources = _load_fridge()
|
||||||
|
online_ids = await _setup_online_then_unload(hass, mock_entry, resources)
|
||||||
|
assert online_ids # guard: the online run must actually produce entities
|
||||||
|
|
||||||
|
with _unreachable():
|
||||||
|
await hass.config_entries.async_setup(mock_entry.entry_id)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert mock_entry.state is ConfigEntryState.LOADED
|
||||||
|
assert {s.entity_id for s in hass.states.async_all()} == online_ids
|
||||||
|
|
||||||
|
|
||||||
|
async def test_offline_entities_are_unavailable_not_stale(hass: HomeAssistant, mock_entry) -> None:
|
||||||
|
"""Restored entities must not render the snapshot's values -- the
|
||||||
|
appliance is unreachable, so `unavailable` is the honest state and the
|
||||||
|
live cache stays empty to enforce it."""
|
||||||
|
resources = _load_fridge()
|
||||||
|
await _setup_online_then_unload(hass, mock_entry, resources)
|
||||||
|
|
||||||
|
with _unreachable():
|
||||||
|
await hass.config_entries.async_setup(mock_entry.entry_id)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
states = hass.states.async_all()
|
||||||
|
assert states
|
||||||
|
assert all(s.state == "unavailable" for s in states)
|
||||||
|
|
||||||
|
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
|
||||||
|
assert coordinator.rehydrated
|
||||||
|
assert not coordinator.last_resources
|
||||||
|
|
||||||
|
|
||||||
|
async def test_offline_load_without_snapshot_still_fails(hass: HomeAssistant, mock_entry) -> None:
|
||||||
|
"""No snapshot means no device metadata to build anything from, so the
|
||||||
|
entry stays on HA's backoff rather than loading empty."""
|
||||||
|
with _unreachable():
|
||||||
|
await hass.config_entries.async_setup(mock_entry.entry_id)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert mock_entry.state is ConfigEntryState.SETUP_RETRY
|
||||||
|
assert not hass.states.async_all()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_malformed_snapshot_falls_back_to_setup_retry(
|
||||||
|
hass: HomeAssistant, mock_entry, hass_storage
|
||||||
|
) -> None:
|
||||||
|
"""A stored row missing a field the current dataclass declares must fail
|
||||||
|
the same way an unreachable device does.
|
||||||
|
|
||||||
|
Anything escaping async_rehydrate reaches async_setup_entry, which only
|
||||||
|
handles ConfigEntryNotReady -- so the entry would land in SETUP_ERROR,
|
||||||
|
which HA never retries, with its DTLS session left open on the fixed
|
||||||
|
source port the next attempt binds.
|
||||||
|
"""
|
||||||
|
key = _store_key(mock_entry)
|
||||||
|
hass_storage[key] = {
|
||||||
|
"version": 1,
|
||||||
|
"minor_version": 1,
|
||||||
|
"key": key,
|
||||||
|
"data": {
|
||||||
|
"resources": _load_fridge(),
|
||||||
|
"subdevice_candidates": [{"key": "1"}], # no "kind"
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
with (
|
||||||
|
_unreachable(),
|
||||||
|
patch.object(LocalThingsCoordinator, "async_close", autospec=True) as close,
|
||||||
|
):
|
||||||
|
await hass.config_entries.async_setup(mock_entry.entry_id)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert mock_entry.state is ConfigEntryState.SETUP_RETRY
|
||||||
|
close.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_corrupt_snapshot_falls_back_to_setup_retry(
|
||||||
|
hass: HomeAssistant, mock_entry, hass_storage
|
||||||
|
) -> None:
|
||||||
|
"""A snapshot whose resources no longer replay cleanly must not take the
|
||||||
|
entry down with it."""
|
||||||
|
key = _store_key(mock_entry)
|
||||||
|
hass_storage[key] = {
|
||||||
|
"version": 1,
|
||||||
|
"minor_version": 1,
|
||||||
|
"key": key,
|
||||||
|
"data": {"resources": {"/information/vs/0": "not-a-rep"}, "subdevice_candidates": []},
|
||||||
|
}
|
||||||
|
|
||||||
|
with _unreachable():
|
||||||
|
await hass.config_entries.async_setup(mock_entry.entry_id)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert mock_entry.state is ConfigEntryState.SETUP_RETRY
|
||||||
|
|
||||||
|
|
||||||
|
async def test_snapshot_restores_identity(hass: HomeAssistant, mock_entry) -> None:
|
||||||
|
"""`/oic/d`'s device types route the registry, so an offline load that
|
||||||
|
lost them could resolve a different one than the live poll did -- which
|
||||||
|
would show up as a spurious reconcile reload every restart."""
|
||||||
|
resources = _load_fridge()
|
||||||
|
identity = DeviceIdentity(
|
||||||
|
manufacturer="Samsung",
|
||||||
|
model="TEST-MODEL",
|
||||||
|
name="Fridge",
|
||||||
|
serial=None,
|
||||||
|
device_types=("oic.d.refrigerator",),
|
||||||
|
raw={"/oic/p": {}, "/oic/d": {}, "/oic/res": []},
|
||||||
|
)
|
||||||
|
with _reachable(resources, identity):
|
||||||
|
await hass.config_entries.async_setup(mock_entry.entry_id)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
await hass.config_entries.async_unload(mock_entry.entry_id)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
with _unreachable():
|
||||||
|
await hass.config_entries.async_setup(mock_entry.entry_id)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
|
||||||
|
assert coordinator._identity is not None
|
||||||
|
assert coordinator._identity.device_types == ("oic.d.refrigerator",)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Recovery and reconciliation
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def test_offline_load_keeps_polling_and_recovers(hass: HomeAssistant, mock_entry) -> None:
|
||||||
|
"""The failure the PR this replaces actually shipped: with zero live
|
||||||
|
listeners the base coordinator stops rescheduling, and the entry never
|
||||||
|
polls again. Entities must go available on the next interval once the
|
||||||
|
appliance answers."""
|
||||||
|
resources = _load_fridge()
|
||||||
|
await _setup_online_then_unload(hass, mock_entry, resources)
|
||||||
|
|
||||||
|
with _unreachable():
|
||||||
|
await hass.config_entries.async_setup(mock_entry.entry_id)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
|
||||||
|
assert coordinator._unsub_refresh is not None # a poll is actually queued
|
||||||
|
assert not coordinator.last_update_success
|
||||||
|
|
||||||
|
with _reachable(resources):
|
||||||
|
await _tick(hass)
|
||||||
|
|
||||||
|
assert coordinator.last_update_success
|
||||||
|
assert any(s.state != "unavailable" for s in hass.states.async_all())
|
||||||
|
|
||||||
|
|
||||||
|
async def test_reconcile_reloads_when_live_discovery_differs(
|
||||||
|
hass: HomeAssistant, mock_entry, hass_storage
|
||||||
|
) -> None:
|
||||||
|
"""Platforms enumerate `bound` once, so a live set that disagrees with the
|
||||||
|
snapshot can only be adopted by bringing the entry back up."""
|
||||||
|
resources = _load_fridge()
|
||||||
|
|
||||||
|
with _reachable(resources):
|
||||||
|
await hass.config_entries.async_setup(mock_entry.entry_id)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
|
||||||
|
victim = coordinator.bound[0].href
|
||||||
|
await hass.config_entries.async_unload(mock_entry.entry_id)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
with _unreachable():
|
||||||
|
await hass.config_entries.async_setup(mock_entry.entry_id)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
reduced = {href: rep for href, rep in resources.items() if href != victim}
|
||||||
|
with (
|
||||||
|
_reachable(reduced),
|
||||||
|
patch.object(hass.config_entries, "async_schedule_reload") as reload,
|
||||||
|
):
|
||||||
|
await _tick(hass)
|
||||||
|
|
||||||
|
reload.assert_called_once_with(mock_entry.entry_id)
|
||||||
|
# Banked before the reload is scheduled, so the entry that comes back up
|
||||||
|
# replays this discovery rather than the one it is replacing -- otherwise
|
||||||
|
# a device that goes quiet again mid-reload rehydrates the stale set and
|
||||||
|
# reconciles all over again.
|
||||||
|
assert victim not in hass_storage[_store_key(mock_entry)]["data"]["resources"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_reconcile_is_quiet_when_live_discovery_agrees(
|
||||||
|
hass: HomeAssistant, mock_entry
|
||||||
|
) -> None:
|
||||||
|
"""The common case -- same appliance, same firmware -- must not reload,
|
||||||
|
or every offline restart would cost a second setup cycle."""
|
||||||
|
resources = _load_fridge()
|
||||||
|
await _setup_online_then_unload(hass, mock_entry, resources)
|
||||||
|
|
||||||
|
with _unreachable():
|
||||||
|
await hass.config_entries.async_setup(mock_entry.entry_id)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
with (
|
||||||
|
_reachable(resources),
|
||||||
|
patch.object(hass.config_entries, "async_schedule_reload") as reload,
|
||||||
|
):
|
||||||
|
await _tick(hass)
|
||||||
|
|
||||||
|
reload.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_coverage_gap_repair_is_live_only(hass: HomeAssistant, mock_entry) -> None:
|
||||||
|
"""A coverage gap is a claim about what the device reports, so replaying
|
||||||
|
a snapshot must not raise the Repair -- it would restate last run's
|
||||||
|
conclusion while the diagnostics download it points at is still empty."""
|
||||||
|
gappy = {
|
||||||
|
"/information/vs/0": {
|
||||||
|
"x.com.samsung.da.modelNum": "TOTALLY_UNKNOWN_BOARD",
|
||||||
|
"x.com.samsung.da.serialNum": "TEST-SERIAL-0000",
|
||||||
|
},
|
||||||
|
"/nothing/maps/this/vs/0": {"someField": 1},
|
||||||
|
}
|
||||||
|
issue_id = f"device_gap_{mock_entry.entry_id}"
|
||||||
|
coordinator = LocalThingsCoordinator(hass, mock_entry)
|
||||||
|
|
||||||
|
coordinator._run_discovery(gappy, from_snapshot=True)
|
||||||
|
assert coordinator._unbound_hrefs # the gap is real, it just stays quiet
|
||||||
|
assert ir.async_get(hass).async_get_issue(DOMAIN, issue_id) is None
|
||||||
|
|
||||||
|
coordinator._run_discovery(gappy)
|
||||||
|
assert ir.async_get(hass).async_get_issue(DOMAIN, issue_id) is not None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_live_load_never_reconciles(
|
||||||
|
hass: HomeAssistant, mock_entry, mock_coordinator_session
|
||||||
|
) -> None:
|
||||||
|
"""An entry that came up against a live device has nothing to reconcile
|
||||||
|
against; the reload path must stay out of the normal startup entirely."""
|
||||||
|
with patch.object(hass.config_entries, "async_schedule_reload") as reload:
|
||||||
|
await hass.config_entries.async_setup(mock_entry.entry_id)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
|
||||||
|
assert not coordinator.rehydrated
|
||||||
|
reload.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("failures", [1, 3])
|
||||||
|
async def test_offline_load_survives_repeated_poll_failures(
|
||||||
|
hass: HomeAssistant, mock_entry, failures: int
|
||||||
|
) -> None:
|
||||||
|
"""Recovery isn't one-shot: the entry keeps its entities and keeps
|
||||||
|
retrying across however many intervals the appliance stays dark."""
|
||||||
|
resources = _load_fridge()
|
||||||
|
online_ids = await _setup_online_then_unload(hass, mock_entry, resources)
|
||||||
|
|
||||||
|
with _unreachable():
|
||||||
|
await hass.config_entries.async_setup(mock_entry.entry_id)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
for _ in range(failures):
|
||||||
|
await _tick(hass)
|
||||||
|
|
||||||
|
assert mock_entry.state is ConfigEntryState.LOADED
|
||||||
|
assert {s.entity_id for s in hass.states.async_all()} == online_ids
|
||||||
|
|
||||||
|
with _reachable(resources):
|
||||||
|
await _tick(hass)
|
||||||
|
|
||||||
|
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
|
||||||
|
assert coordinator.last_update_success
|
||||||
@@ -36,6 +36,15 @@ class _FakeCoordinator:
|
|||||||
def canonical_resources(self, subdevice):
|
def canonical_resources(self, subdevice):
|
||||||
return self.last_resources
|
return self.last_resources
|
||||||
|
|
||||||
|
# _is_included judges existence against the discovery view, which is the
|
||||||
|
# live cache for everything but an offline load (issue #295).
|
||||||
|
@property
|
||||||
|
def discovery_resources(self):
|
||||||
|
return self.last_resources
|
||||||
|
|
||||||
|
def discovery_canonical(self, subdevice):
|
||||||
|
return self.canonical_resources(subdevice)
|
||||||
|
|
||||||
|
|
||||||
def _resources():
|
def _resources():
|
||||||
return _load_device("airconditioner_ailp_fac")
|
return _load_device("airconditioner_ailp_fac")
|
||||||
|
|||||||
@@ -29,6 +29,15 @@ class _FakeCoordinator:
|
|||||||
# subdevices (issue #177).
|
# subdevices (issue #177).
|
||||||
return self.last_resources
|
return self.last_resources
|
||||||
|
|
||||||
|
# _is_included judges existence against the discovery view, which is the
|
||||||
|
# live cache for everything but an offline load (issue #295).
|
||||||
|
@property
|
||||||
|
def discovery_resources(self):
|
||||||
|
return self.last_resources
|
||||||
|
|
||||||
|
def discovery_canonical(self, subdevice):
|
||||||
|
return self.canonical_resources(subdevice)
|
||||||
|
|
||||||
|
|
||||||
def _coord(last_resources) -> LocalThingsCoordinator:
|
def _coord(last_resources) -> LocalThingsCoordinator:
|
||||||
return cast(LocalThingsCoordinator, _FakeCoordinator(last_resources))
|
return cast(LocalThingsCoordinator, _FakeCoordinator(last_resources))
|
||||||
|
|||||||
@@ -34,6 +34,15 @@ class _FakeCoordinator:
|
|||||||
|
|
||||||
return canonical_view(subdevice, self.last_resources, self._subdevices)
|
return canonical_view(subdevice, self.last_resources, self._subdevices)
|
||||||
|
|
||||||
|
# _is_included judges existence against the discovery view, which is the
|
||||||
|
# live cache for everything but an offline load (issue #295).
|
||||||
|
@property
|
||||||
|
def discovery_resources(self):
|
||||||
|
return self.last_resources
|
||||||
|
|
||||||
|
def discovery_canonical(self, subdevice):
|
||||||
|
return self.canonical_resources(subdevice)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("name", _FIXTURE_NAMES)
|
@pytest.mark.parametrize("name", _FIXTURE_NAMES)
|
||||||
def test_key_is_unique_across_all_bound_entities(name):
|
def test_key_is_unique_across_all_bound_entities(name):
|
||||||
|
|||||||
Reference in New Issue
Block a user