Files
Marc Billow e684146f61 Load a config entry offline from the last discovery snapshot (#295)
An appliance switched off at the wall used to take its whole config entry
down with it: async_setup_entry raised ConfigEntryNotReady, so the device
read as failed and its entities existed only as registry rows until the
appliance came back.

Loading the entry anyway isn't enough on its own. Entities here are the
output of discovery, discovery only runs inside a successful poll, and
platforms enumerate `bound` exactly once at forward time -- so an entry
that loads while offline loads empty, and with no listeners subscribed the
base coordinator stops rescheduling and never polls again.

Bank the resources dict each successful first cycle hands _run_discovery,
along with the subdevice candidate list and the /oic identity that route
the registry, and replay it through _run_discovery when the first refresh
fails. Storing the poll input rather than a rendered entity list keeps one
implementation of discovery instead of two: the offline entity set is
produced by the same code that produced the live one.

Three things fall out of that:

- Platforms judge entity existence against `discovery_resources`, not the
  live cache. The live cache deliberately stays empty, which is what keeps
  a restored entity `unavailable` rather than rendering a stale value for
  an appliance nobody can currently reach.
- A live discovery that disagrees with the snapshot reloads the entry --
  platforms can't adopt a changed set in place, so a firmware update or a
  sibling subdevice that starts answering needs a fresh setup.
- The entry holds one coordinator listener for its lifetime, so polling is
  scheduled regardless of how many entities are live.

An entry that has never reached the device has no snapshot, keeps raising
ConfigEntryNotReady, and closes its session on the way out as before -- no
metadata to build a device from, and it leaves room for setup flows that
need to interact with the appliance (#168).

Restores the two tests PR #303 rewrote, narrowed to that no-snapshot path.
2026-08-15 20:05:58 +00:00

108 lines
4.3 KiB
Python

"""Tests for entity._is_included -- the one-time entity-creation gate run
per platform at async_setup_entry (see sensor.py etc.), separate from
adapter.flatten()'s per-poll state values. issue #127 review: this default
gate has to stay permissive on a genuinely empty {} rep, not just a true
is_stub_rep stub -- a resource like /alarms/vs/0 is validly empty in its
normal, no-alarm state (fridge._active_alarm_codes), so excluding the
entity there would silently drop working sensors on first-poll timing,
not just fix phantom ones.
"""
from typing import cast
from custom_components.localthings.coordinator import LocalThingsCoordinator
from custom_components.localthings.entity import _is_included
from custom_components.localthings.registry.capability import Capability
from custom_components.localthings.registry.discovery import BoundEntity
from custom_components.localthings.registry.entities import SensorDesc
class _FakeCoordinator:
def __init__(self, last_resources):
self.last_resources = last_resources
def canonical_resources(self, subdevice):
# Every bound entity in this test file uses the default MAIN
# subdevice (identity transform), so the canonical view is just the
# raw snapshot -- same shape as the real
# LocalThingsCoordinator.canonical_resources for a device with no
# subdevices (issue #177).
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:
return cast(LocalThingsCoordinator, _FakeCoordinator(last_resources))
def _bound(desc, href):
capability = Capability(href=href, entities=(desc,))
return BoundEntity(href=href, capability=capability, desc=desc)
class TestDefaultFieldGate:
"""No explicit exists_fn -- the field-presence default in entity.py."""
def _bound(self):
return _bound(SensorDesc(key="x", field="x.com.samsung.da.value"), "/x/vs/0")
def test_included_when_field_present(self):
bound = self._bound()
coord = _coord({"/x/vs/0": {"x.com.samsung.da.value": "1"}})
assert _is_included(bound, coord) is True
def test_excluded_when_field_absent_from_populated_rep(self):
bound = self._bound()
coord = _coord({"/x/vs/0": {"x.com.samsung.da.other": "1"}})
assert _is_included(bound, coord) is False
def test_included_on_true_stub(self):
bound = self._bound()
coord = _coord({"/x/vs/0": {"href": "/x/vs/0"}})
assert _is_included(bound, coord) is True
def test_included_on_genuinely_empty_rep(self):
"""The default gate stays permissive on a real {} -- see
/alarms/vs/0, whose empty rep is the normal no-alarm state, not a
signal the hardware is unsupported. Only a capability-specific
exists_fn (e.g. common.ENERGY_METER) opts into excluding on
confirmed-empty, after verifying that's actually safe for its field."""
bound = self._bound()
coord = _coord({"/x/vs/0": {}})
assert _is_included(bound, coord) is True
def test_excluded_when_href_missing_from_resources(self):
bound = self._bound()
coord = _coord({})
assert _is_included(bound, coord) is False
class TestExplicitExistsFnGate:
def test_explicit_exists_fn_overrides_default_field_gate(self):
desc = SensorDesc(
key="x",
field="x.com.samsung.da.value",
exists_fn=lambda rep, resources: rep.get("flag") is True,
)
bound = _bound(desc, "/x/vs/0")
coord = _coord({"/x/vs/0": {"flag": True}})
assert _is_included(bound, coord) is True
coord = _coord({"/x/vs/0": {"flag": False, "x.com.samsung.da.value": "1"}})
assert _is_included(bound, coord) is False
class TestNoFieldEntities:
def test_rep_fn_entity_always_included(self):
desc = SensorDesc(key="x", rep_fn=lambda rep: rep.get("x.com.samsung.da.value"))
bound = _bound(desc, "/x/vs/0")
coord = _coord({"/x/vs/0": {}})
assert _is_included(bound, coord) is True