Fix hot/warm href tiers dropped for no-entity coverage capabilities

discover() only emitted a BoundEntity per capability *entity*, so a
coverage-only Capability (entities=(), used to mark a href as handled
elsewhere -- e.g. the AC climate card's wind/strength, wind/direction,
temperature/control hrefs) produced zero rows. The coordinator computed
its hot/warm href lists by walking `bound`, so every such href's
poll_tier was silently discarded and it fell back to the ~30s summary
poll only -- no sub-poll cadence and never attempted for OCF OBSERVE.

This is the root cause of issue #166's "up to a minute" lag for
remote-driven fan-speed changes: /wind/strength/vs/0 carries poll_tier
'warm' via airconditioner.COVERAGE but never reached
_hot_hrefs/_warm_hrefs, so it wasn't in the OBSERVE-attempt href list
and only refreshed on the summary poll.

discover() now takes an optional tier_log(href, poll_tier) callback
fired for every href a capability matches, entities or not. The
coordinator uses it directly instead of deriving tiers from `bound`.
This commit is contained in:
Marc Billow
2026-07-28 15:08:12 +00:00
parent 65b0326d62
commit 71e026b804
3 changed files with 38 additions and 9 deletions
+10 -9
View File
@@ -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)
@@ -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:
+16
View File
@@ -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')]