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.
108 lines
4.3 KiB
Python
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
|