diff --git a/custom_components/localthings/coordinator.py b/custom_components/localthings/coordinator.py index d7591f1..65c728a 100644 --- a/custom_components/localthings/coordinator.py +++ b/custom_components/localthings/coordinator.py @@ -366,14 +366,22 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]): if reg is None: reg = for_device_by_resources(resources) unbound: list[str] = [] + hot, warm = set(), set() + + def _tier_log(href: str, tier: str) -> None: + if tier == 'hot': + hot.add(href) + elif tier == 'warm': + warm.add(href) + if reg is not None: self._log.debug("device type: %s (oneUiVersion=%r)", reg.name, one_ui) bound = discover(resources, reg.capabilities, reg.pattern_capabilities, - log=unbound.append) + log=unbound.append, tier_log=_tier_log) self.device_type_name = reg.name else: self._log.warning("unknown device type oneUiVersion=%r; using common caps", one_ui) - bound = discover(resources, CAPABILITIES, log=unbound.append) + bound = discover(resources, CAPABILITIES, log=unbound.append, tier_log=_tier_log) self.device_type_name = None self.bound = bound self._unbound_hrefs = unbound @@ -398,13 +406,6 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]): ) self._update_coverage_gap_issue(reg is None, unbound, name) - hot, warm = set(), set() - for be in bound: - tier = be.capability.poll_tier - if tier == 'hot': - hot.add(be.href) - elif tier == 'warm': - warm.add(be.href) self._hot_hrefs = sorted(hot) self._warm_hrefs = sorted(warm) diff --git a/custom_components/localthings/registry/discovery.py b/custom_components/localthings/registry/discovery.py index 18cbaf9..2e2cbd1 100644 --- a/custom_components/localthings/registry/discovery.py +++ b/custom_components/localthings/registry/discovery.py @@ -73,7 +73,15 @@ def discover( registry: dict[str, list[Capability]], pattern_caps: Iterable[Capability] = (), log: Optional[Callable[[str], None]] = None, + tier_log: Optional[Callable[[str, str], None]] = None, ) -> list[BoundEntity]: + """`tier_log(href, poll_tier)` fires for every href a capability actually + matches, even a no-entity "coverage-only" capability (see COVERAGE lists + in capabilities/*.py) that `_bind()` turns into zero `BoundEntity` rows. + Callers that need a href's poll cadence (the coordinator's hot/warm + sub-poll and OBSERVE-attempt lists) must use this, not `bound` -- a + coverage-only capability's `poll_tier` would otherwise be silently + dropped since it never appears in `bound`.""" out: list[BoundEntity] = [] for href, rep in resources.items(): @@ -91,6 +99,8 @@ def discover( inst = instance_suffix(href) out.extend(_bind(cap, href, inst, _instance_name(cap, rep))) matched = True + if tier_log is not None: + tier_log(href, cap.poll_tier) if matched: continue @@ -109,6 +119,8 @@ def discover( segs = [s for s in src.strip('/').split('/') if s and not s.isdigit() and s != 'vs'] out.extend(_bind(cap, href, inst, _instance_name(cap, rep), '_'.join(segs))) matched = True + if tier_log is not None: + tier_log(href, cap.poll_tier) break if not matched and not caps and log is not None: diff --git a/tests/test_discovery.py b/tests/test_discovery.py index 59702e2..89591e8 100644 --- a/tests/test_discovery.py +++ b/tests/test_discovery.py @@ -209,3 +209,19 @@ def test_discover_rt_filter_gates_binding(): resources = {'/mode/vs/0': {'rt': ['x.com.samsung.da.mode'], 'x.com.samsung.da.mode': 'Bake'}} bound = discover(resources, {'/mode/vs/0': [oven_mode_cap]}) assert bound == [] + + +def test_discover_tier_log_fires_for_no_entity_coverage_cap(): + """A coverage-only Capability (entities=(), used to mark a href as + handled by e.g. a composite climate entity rather than its own HA + entity) produces zero BoundEntity rows -- `bound` alone can't tell a + caller its poll_tier. tier_log must still report it, or a consumer + (the coordinator's hot/warm href lists) silently treats it as 'cold' + regardless of what poll_tier the capability actually declares.""" + coverage_cap = Capability(href='/wind/strength/vs/0', poll_tier='warm') + resources = {'/wind/strength/vs/0': {'x.com.samsung.da.modes': '0'}} + seen = [] + bound = discover(resources, {coverage_cap.href: [coverage_cap]}, + tier_log=lambda href, tier: seen.append((href, tier))) + assert bound == [] + assert seen == [('/wind/strength/vs/0', 'warm')]