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.
94 lines
4.5 KiB
Python
94 lines
4.5 KiB
Python
"""Corpus-wide invariant: adapter._key must be unique across every bound
|
|
entity that would actually be registered as an HA entity, for every fixture
|
|
in the corpus -- issue #177's whole Subdevice/key_prefix design exists to
|
|
protect this (see DESIGN-177.md section 3/6). Run over the entire fixture
|
|
set, not just the two new subdevice fixtures, so a future dump -- subdevice-
|
|
capable or not -- exercises it automatically.
|
|
"""
|
|
|
|
from collections import Counter
|
|
from typing import cast
|
|
|
|
import pytest
|
|
|
|
from custom_components.localthings.coordinator import LocalThingsCoordinator
|
|
from custom_components.localthings.entity import _is_included
|
|
from custom_components.localthings.registry.adapter import _key
|
|
from custom_components.localthings.registry.entities import PLATFORM_OF
|
|
from tests.conftest import FIXTURES, _discover_full, _load_device_full
|
|
|
|
_FIXTURE_NAMES = sorted(p.name[: -len("_device.json")] for p in FIXTURES.glob("*_device.json"))
|
|
|
|
|
|
class _FakeCoordinator:
|
|
"""Just enough of LocalThingsCoordinator's surface for entity.py's
|
|
_is_included -- the same one-time entity-creation gate every platform's
|
|
async_setup_entry runs (see entity.py's own module docstring)."""
|
|
|
|
def __init__(self, resources: dict[str, dict], subdevices):
|
|
self.last_resources = resources
|
|
self._subdevices = list(subdevices)
|
|
|
|
def canonical_resources(self, subdevice):
|
|
from custom_components.localthings.registry.subdevices import canonical_view
|
|
|
|
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)
|
|
def test_key_is_unique_across_all_bound_entities(name):
|
|
"""`_key(b)` only has to be unique among entities `_is_included` would
|
|
actually register -- discover() alone can (deliberately) produce two
|
|
BoundEntity rows sharing a key on the same href when they're gated by
|
|
mutually-exclusive `exists_fn`s (see fridge.py's
|
|
REFRIGERATION_FALLBACK.defrost_active, which only exists when
|
|
DEFROST_BLOCK_STATUS's own `/defrost/block/vs/0` href is absent) --
|
|
only one of the two is ever actually included for a real device, so
|
|
checking the raw `bound` list would flag devices that have always
|
|
worked correctly. It also only has to be unique *within one HA
|
|
platform* -- unique_id collisions are scoped by (integration, platform)
|
|
in HA's entity registry (see entity.py's `_attr_unique_id`, which
|
|
doesn't itself encode the platform), and several devices in this corpus
|
|
deliberately bind the same key to the same href on two different
|
|
platforms discriminated the same way (e.g. a SwitchDesc and a
|
|
BinarySensorDesc both named 'power_switch' on /power/0).
|
|
"""
|
|
resources, oic_res, seeds = _load_device_full(name)
|
|
bound, materialized, _skipped, full_resources, _device_type_name = _discover_full(
|
|
resources,
|
|
oic_res,
|
|
seeds,
|
|
)
|
|
coordinator = cast(LocalThingsCoordinator, _FakeCoordinator(full_resources, materialized))
|
|
included = [b for b in bound if _is_included(b, coordinator)]
|
|
keys = [(PLATFORM_OF[type(b.desc)], _key(b)) for b in included]
|
|
dupes = {k: n for k, n in Counter(keys).items() if n > 1}
|
|
assert not dupes, (
|
|
f"{name}: duplicate (platform, _key) values across {len(included)} "
|
|
f"included entities (materialized subdevices: "
|
|
f"{[su.key for su in materialized]}): {dupes}"
|
|
)
|
|
|
|
|
|
def test_subdevice_capable_fixtures_actually_exercise_a_subdevice():
|
|
"""A meta-check on the test above: if both subdevice fixtures somehow
|
|
stopped materializing any subdevice (a regression in enumeration or the
|
|
materialization gate), the corpus-wide uniqueness test above would keep
|
|
passing vacuously -- it never gets to check a single collision. Assert
|
|
the two fixtures this design added actually produce a materialized
|
|
subdevice, so that silent-vacuous-pass failure mode is caught here
|
|
instead."""
|
|
for name in ("airconditioner_artik051_dongle_fac_18k", "airconditioner_fac_bora_2in1"):
|
|
resources, oic_res, seeds = _load_device_full(name)
|
|
_, materialized, _, _, _ = _discover_full(resources, oic_res, seeds)
|
|
assert materialized, f"{name}: expected at least one materialized subdevice"
|