Bound subdevice discovery during setup
This commit is contained in:
@@ -165,6 +165,17 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
_POST_TIMEOUT_S: float = 8.0
|
_POST_TIMEOUT_S: float = 8.0
|
||||||
_POLL_TIMEOUT_S: float = 35.0
|
_POLL_TIMEOUT_S: float = 35.0
|
||||||
|
|
||||||
|
# First-discovery subdevice enumeration is part of config-entry setup, so
|
||||||
|
# it must have a finite wall-clock cost. A UUID-prefixed AC whose
|
||||||
|
# /<uuid>/device/0 Collection is absent falls back to individual property
|
||||||
|
# probes; some firmware silently drops unknown prefixed paths instead of
|
||||||
|
# returning 4.04, making the old 10s-per-href scan take several minutes.
|
||||||
|
# Keep enough time for a real blockwise Collection response, then use
|
||||||
|
# short timeouts for the small Property resources, all under one budget.
|
||||||
|
_SUBDEVICE_ENUMERATION_BUDGET_S: float = 15.0
|
||||||
|
_SUBDEVICE_COLLECTION_TIMEOUT_S: float = 10.0
|
||||||
|
_SUBDEVICE_PROPERTY_TIMEOUT_S: float = 1.0
|
||||||
|
|
||||||
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
|
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||||
# Per-device logger (module logger scoped to this device's host) so
|
# Per-device logger (module logger scoped to this device's host) so
|
||||||
# every log line — including the base DataUpdateCoordinator's own
|
# every log line — including the base DataUpdateCoordinator's own
|
||||||
@@ -551,6 +562,45 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
# Discovery (runs once on first successful poll)
|
# Discovery (runs once on first successful poll)
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _subdevice_probe_priority(
|
||||||
|
self, resources: dict[str, dict],
|
||||||
|
) -> tuple[str, ...]:
|
||||||
|
"""Return live primary-entity hrefs in hot/warm-first order.
|
||||||
|
|
||||||
|
A prefixed subdevice without a Collection endpoint has to be probed
|
||||||
|
one Property href at a time. Resolve the master through the same
|
||||||
|
registry used by discovery and put resources that produce primary
|
||||||
|
entities first. This is metadata-driven rather than an AC-specific
|
||||||
|
list: a future composite appliance gets the priority its own registry
|
||||||
|
declares, while unknown devices simply retain the normal href order.
|
||||||
|
"""
|
||||||
|
registry = resolve_registry(
|
||||||
|
resources,
|
||||||
|
device_types=self._identity.device_types if self._identity else (),
|
||||||
|
)
|
||||||
|
if registry is None:
|
||||||
|
return ()
|
||||||
|
|
||||||
|
tier_rank = {'hot': 0, 'warm': 1, 'cold': 2}
|
||||||
|
ranked = []
|
||||||
|
for order, href in enumerate(resources):
|
||||||
|
primary_caps = [
|
||||||
|
capability
|
||||||
|
for capability in registry.capabilities.get(href, ())
|
||||||
|
if any(
|
||||||
|
desc.entity_category is None
|
||||||
|
for desc in capability.entities
|
||||||
|
)
|
||||||
|
]
|
||||||
|
if not primary_caps:
|
||||||
|
continue
|
||||||
|
rank = min(
|
||||||
|
tier_rank.get(capability.poll_tier, 2)
|
||||||
|
for capability in primary_caps
|
||||||
|
)
|
||||||
|
ranked.append((rank, order, href))
|
||||||
|
return tuple(href for _, _, href in sorted(ranked))
|
||||||
|
|
||||||
def _enumerate_subdevices_blocking(self, resources: dict[str, dict]) -> dict[str, dict]:
|
def _enumerate_subdevices_blocking(self, resources: dict[str, dict]) -> dict[str, dict]:
|
||||||
"""One-time (first discovery only) probe for sibling indoor subdevices
|
"""One-time (first discovery only) probe for sibling indoor subdevices
|
||||||
sharing this connection (issue #177) -- see
|
sharing this connection (issue #177) -- see
|
||||||
@@ -578,6 +628,10 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
subdevices, extra = enumerate_subdevices(
|
subdevices, extra = enumerate_subdevices(
|
||||||
sess, resources, oic_res,
|
sess, resources, oic_res,
|
||||||
probe_log=lambda href, found: probes.__setitem__(href, found),
|
probe_log=lambda href, found: probes.__setitem__(href, found),
|
||||||
|
preferred_hrefs=self._subdevice_probe_priority(resources),
|
||||||
|
time_budget=self._SUBDEVICE_ENUMERATION_BUDGET_S,
|
||||||
|
collection_timeout=self._SUBDEVICE_COLLECTION_TIMEOUT_S,
|
||||||
|
property_timeout=self._SUBDEVICE_PROPERTY_TIMEOUT_S,
|
||||||
)
|
)
|
||||||
self.subdevices = subdevices
|
self.subdevices = subdevices
|
||||||
self._subdevice_probes = probes
|
self._subdevice_probes = probes
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ that materialized the slot as a phantom second air conditioner. See
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
import re
|
||||||
|
import time
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Callable, Optional, Sequence
|
from typing import Callable, Optional, Sequence
|
||||||
|
|
||||||
@@ -269,13 +270,13 @@ def _seed_href(path_segs: tuple[str, ...]) -> str:
|
|||||||
return '/' + '/'.join(path_segs)
|
return '/' + '/'.join(path_segs)
|
||||||
|
|
||||||
|
|
||||||
def _get_raw(sess, path_segs: tuple[str, ...]):
|
def _get_raw(sess, path_segs: tuple[str, ...], timeout: float = 10.0):
|
||||||
"""GET `path_segs` and CBOR-decode the payload, or None on any
|
"""GET `path_segs` and CBOR-decode the payload, or None on any
|
||||||
missing/malformed response (a 4.04, a timeout, an empty payload) --
|
missing/malformed response (a 4.04, a timeout, an empty payload) --
|
||||||
shared tolerated-absence posture for both callers below, which differ
|
shared tolerated-absence posture for both callers below, which differ
|
||||||
only in which body shape they accept."""
|
only in which body shape they accept."""
|
||||||
try:
|
try:
|
||||||
code, pl = sess.get(list(path_segs), timeout=10.0)
|
code, pl = sess.get(list(path_segs), timeout=timeout)
|
||||||
if code == 0x45 and pl:
|
if code == 0x45 and pl:
|
||||||
return cbor2.loads(pl)
|
return cbor2.loads(pl)
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -283,21 +284,25 @@ def _get_raw(sess, path_segs: tuple[str, ...]):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _get_batch(sess, path_segs: tuple[str, ...]) -> dict[str, dict]:
|
def _get_batch(
|
||||||
|
sess, path_segs: tuple[str, ...], timeout: float = 10.0,
|
||||||
|
) -> dict[str, dict]:
|
||||||
"""GET a Samsung Collection resource and parse it the same way
|
"""GET a Samsung Collection resource and parse it the same way
|
||||||
/device/0 itself is parsed (parse_device0_batch): a [devcol-rep,
|
/device/0 itself is parsed (parse_device0_batch): a [devcol-rep,
|
||||||
{href, rep}, ...] CBOR list, not a bare Property map."""
|
{href, rep}, ...] CBOR list, not a bare Property map."""
|
||||||
body = _get_raw(sess, path_segs)
|
body = _get_raw(sess, path_segs, timeout)
|
||||||
return parse_device0_batch(body) if isinstance(body, list) else {}
|
return parse_device0_batch(body) if isinstance(body, list) else {}
|
||||||
|
|
||||||
|
|
||||||
def _get_property(sess, path_segs: tuple[str, ...]) -> dict:
|
def _get_property(
|
||||||
|
sess, path_segs: tuple[str, ...], timeout: float = 10.0,
|
||||||
|
) -> dict:
|
||||||
"""GET a plain OCF Property-map resource (a bare dict, not a Collection
|
"""GET a plain OCF Property-map resource (a bare dict, not a Collection
|
||||||
batch). Used for `/multidevice/vs/0` (issue #177 follow-up): listed in
|
batch). Used for `/multidevice/vs/0` (issue #177 follow-up): listed in
|
||||||
`/oic/res` on the Pattern A reporter's board but absent from
|
`/oic/res` on the Pattern A reporter's board but absent from
|
||||||
`/device/0`'s batch, so it needs its own RETRIEVE, and it answers a
|
`/device/0`'s batch, so it needs its own RETRIEVE, and it answers a
|
||||||
single Property map, not a [devcol-rep, ...] list."""
|
single Property map, not a [devcol-rep, ...] list."""
|
||||||
body = _get_raw(sess, path_segs)
|
body = _get_raw(sess, path_segs, timeout)
|
||||||
return body if isinstance(body, dict) else {}
|
return body if isinstance(body, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
@@ -306,6 +311,11 @@ def enumerate_subdevices(
|
|||||||
resources: dict[str, dict],
|
resources: dict[str, dict],
|
||||||
oic_res_links,
|
oic_res_links,
|
||||||
probe_log: Optional[Callable[[str, bool], None]] = None,
|
probe_log: Optional[Callable[[str, bool], None]] = None,
|
||||||
|
*,
|
||||||
|
preferred_hrefs: Sequence[str] = (),
|
||||||
|
time_budget: Optional[float] = None,
|
||||||
|
collection_timeout: float = 10.0,
|
||||||
|
property_timeout: float = 10.0,
|
||||||
) -> tuple[list['Subdevice'], dict[str, dict]]:
|
) -> tuple[list['Subdevice'], dict[str, dict]]:
|
||||||
"""Discover every sibling indoor subdevice reachable over `sess`'s
|
"""Discover every sibling indoor subdevice reachable over `sess`'s
|
||||||
connection.
|
connection.
|
||||||
@@ -323,6 +333,13 @@ def enumerate_subdevices(
|
|||||||
posture the speculative-probe code this replaces used to document in
|
posture the speculative-probe code this replaces used to document in
|
||||||
identity.py.
|
identity.py.
|
||||||
|
|
||||||
|
`preferred_hrefs` only changes the order of the flat Property fallback;
|
||||||
|
it never filters the device's resource surface. When `time_budget` is
|
||||||
|
supplied, probes are bounded by one shared monotonic deadline and this
|
||||||
|
returns every candidate/resource confirmed before it. This makes first
|
||||||
|
setup finite even when firmware silently drops unknown paths instead of
|
||||||
|
returning 4.04.
|
||||||
|
|
||||||
Every candidate whose seed answers with a non-empty batch is returned
|
Every candidate whose seed answers with a non-empty batch is returned
|
||||||
here -- this function has no way to tell a real sibling from an unused
|
here -- this function has no way to tell a real sibling from an unused
|
||||||
SmartThings slot that merely answers the same shape (the Pattern A
|
SmartThings slot that merely answers the same shape (the Pattern A
|
||||||
@@ -333,6 +350,33 @@ def enumerate_subdevices(
|
|||||||
"""
|
"""
|
||||||
subdevices: list[Subdevice] = []
|
subdevices: list[Subdevice] = []
|
||||||
fetched: dict[str, dict] = {}
|
fetched: dict[str, dict] = {}
|
||||||
|
deadline = (
|
||||||
|
time.monotonic() + max(0.0, time_budget)
|
||||||
|
if time_budget is not None else None
|
||||||
|
)
|
||||||
|
budget_exhausted = False
|
||||||
|
|
||||||
|
def _next_timeout(maximum: float) -> Optional[float]:
|
||||||
|
"""Clamp one probe to the remaining enumeration wall-clock budget."""
|
||||||
|
nonlocal budget_exhausted
|
||||||
|
if deadline is None:
|
||||||
|
return maximum
|
||||||
|
remaining = deadline - time.monotonic()
|
||||||
|
if remaining <= 0:
|
||||||
|
budget_exhausted = True
|
||||||
|
return None
|
||||||
|
return min(maximum, remaining)
|
||||||
|
|
||||||
|
def _flat_probe_hrefs():
|
||||||
|
"""Preferred live-state hrefs first, then every remaining master href."""
|
||||||
|
seen = set()
|
||||||
|
for href in preferred_hrefs:
|
||||||
|
if href in resources and href not in seen:
|
||||||
|
seen.add(href)
|
||||||
|
yield href
|
||||||
|
for href in sorted(resources):
|
||||||
|
if href not in seen:
|
||||||
|
yield href
|
||||||
|
|
||||||
def _probed(seed_href: str, batch: dict) -> None:
|
def _probed(seed_href: str, batch: dict) -> None:
|
||||||
if probe_log is not None:
|
if probe_log is not None:
|
||||||
@@ -350,7 +394,10 @@ def enumerate_subdevices(
|
|||||||
ids = raw_ids if isinstance(raw_ids, list) else []
|
ids = raw_ids if isinstance(raw_ids, list) else []
|
||||||
for sub_id in sorted(i for i in ids if isinstance(i, str) and i):
|
for sub_id in sorted(i for i in ids if isinstance(i, str) and i):
|
||||||
seed = (sub_id, 'device', '0')
|
seed = (sub_id, 'device', '0')
|
||||||
batch = _get_batch(sess, seed)
|
timeout = _next_timeout(collection_timeout)
|
||||||
|
if timeout is None:
|
||||||
|
break
|
||||||
|
batch = _get_batch(sess, seed, timeout)
|
||||||
_probed(_seed_href(seed), batch)
|
_probed(_seed_href(seed), batch)
|
||||||
if batch:
|
if batch:
|
||||||
subdevice = Subdevice(kind='prefixed', key=sub_id, seed_path=seed)
|
subdevice = Subdevice(kind='prefixed', key=sub_id, seed_path=seed)
|
||||||
@@ -366,9 +413,9 @@ def enumerate_subdevices(
|
|||||||
# device's siblings are the same physical board family as the
|
# device's siblings are the same physical board family as the
|
||||||
# subdevice this config entry already talks to -- so probe every
|
# subdevice this config entry already talks to -- so probe every
|
||||||
# href the master itself answered this cycle, individually, under
|
# href the master itself answered this cycle, individually, under
|
||||||
# this UUID's prefix, and keep whichever ones answer. Each is a
|
# this UUID's prefix, and keep whichever ones answer before the
|
||||||
# plain tolerated-404 RETRIEVE, same posture as every other probe in
|
# optional enumeration deadline. Each is a plain tolerated-404
|
||||||
# this function.
|
# RETRIEVE, same posture as every other probe in this function.
|
||||||
#
|
#
|
||||||
# Known gap, not yet guarded against: a firmware that answers *any*
|
# Known gap, not yet guarded against: a firmware that answers *any*
|
||||||
# request under an unrecognized prefix (echoing the master's own
|
# request under an unrecognized prefix (echoing the master's own
|
||||||
@@ -382,12 +429,17 @@ def enumerate_subdevices(
|
|||||||
# against the master's own values for those same canonical hrefs.
|
# against the master's own values for those same canonical hrefs.
|
||||||
flat_hrefs = []
|
flat_hrefs = []
|
||||||
first = True
|
first = True
|
||||||
for href in sorted(resources):
|
for href in _flat_probe_hrefs():
|
||||||
if not first:
|
if not first:
|
||||||
sess.pace()
|
sess.pace()
|
||||||
first = False
|
first = False
|
||||||
|
timeout = _next_timeout(property_timeout)
|
||||||
|
if timeout is None:
|
||||||
|
break
|
||||||
actual = f'/{sub_id}{href}'
|
actual = f'/{sub_id}{href}'
|
||||||
rep = _get_property(sess, tuple(actual.strip('/').split('/')))
|
rep = _get_property(
|
||||||
|
sess, tuple(actual.strip('/').split('/')), timeout,
|
||||||
|
)
|
||||||
_probed(actual, bool(rep))
|
_probed(actual, bool(rep))
|
||||||
if rep:
|
if rep:
|
||||||
flat_hrefs.append(href)
|
flat_hrefs.append(href)
|
||||||
@@ -398,6 +450,8 @@ def enumerate_subdevices(
|
|||||||
kind='prefixed', key=sub_id, seed_path=(),
|
kind='prefixed', key=sub_id, seed_path=(),
|
||||||
flat_hrefs=tuple(flat_hrefs),
|
flat_hrefs=tuple(flat_hrefs),
|
||||||
))
|
))
|
||||||
|
if budget_exhausted:
|
||||||
|
break
|
||||||
|
|
||||||
# --- Pattern A: indexed siblings (ARTIK051_DONGLE_FAC_18K) --------------
|
# --- Pattern A: indexed siblings (ARTIK051_DONGLE_FAC_18K) --------------
|
||||||
indices = sorted({
|
indices = sorted({
|
||||||
@@ -413,8 +467,11 @@ def enumerate_subdevices(
|
|||||||
# bounded speculative probe this replaces from identity.py.
|
# bounded speculative probe this replaces from identity.py.
|
||||||
indices = list(_SPECULATIVE_DEVICE_INDICES)
|
indices = list(_SPECULATIVE_DEVICE_INDICES)
|
||||||
for n in indices:
|
for n in indices:
|
||||||
|
timeout = _next_timeout(collection_timeout)
|
||||||
|
if timeout is None:
|
||||||
|
break
|
||||||
seed = ('device', str(n))
|
seed = ('device', str(n))
|
||||||
batch = _get_batch(sess, seed)
|
batch = _get_batch(sess, seed, timeout)
|
||||||
_probed(_seed_href(seed), batch)
|
_probed(_seed_href(seed), batch)
|
||||||
if not batch:
|
if not batch:
|
||||||
continue
|
continue
|
||||||
@@ -436,10 +493,12 @@ def enumerate_subdevices(
|
|||||||
# coordinator's call to log (it owns the logger; this module doesn't),
|
# coordinator's call to log (it owns the logger; this module doesn't),
|
||||||
# not this function's.
|
# not this function's.
|
||||||
multidevice_seed = ('multidevice', 'vs', '0')
|
multidevice_seed = ('multidevice', 'vs', '0')
|
||||||
multidevice = _get_property(sess, multidevice_seed)
|
timeout = _next_timeout(property_timeout)
|
||||||
_probed(_seed_href(multidevice_seed), multidevice)
|
if timeout is not None:
|
||||||
if multidevice:
|
multidevice = _get_property(sess, multidevice_seed, timeout)
|
||||||
fetched['/multidevice/vs/0'] = multidevice
|
_probed(_seed_href(multidevice_seed), multidevice)
|
||||||
|
if multidevice:
|
||||||
|
fetched['/multidevice/vs/0'] = multidevice
|
||||||
|
|
||||||
return subdevices, fetched
|
return subdevices, fetched
|
||||||
|
|
||||||
|
|||||||
@@ -253,6 +253,23 @@ async def test_fac_bora_2in1_unique_ids_include_subdevice_prefix(hass: HomeAssis
|
|||||||
# capture instead of the synthetic sessions test_subdevices.py uses.
|
# capture instead of the synthetic sessions test_subdevices.py uses.
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def test_flat_probe_priority_puts_live_climate_state_before_cold_metrics(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
):
|
||||||
|
"""Registry metadata drives fallback order without a model-specific list."""
|
||||||
|
resources, _oic_res, _seeds = _load_device_full(
|
||||||
|
'airconditioner_fac_bora_205_flat'
|
||||||
|
)
|
||||||
|
coordinator = _coordinator(hass)
|
||||||
|
|
||||||
|
priority = coordinator._subdevice_probe_priority(resources)
|
||||||
|
|
||||||
|
assert '/mode/vs/0' in priority[:4]
|
||||||
|
assert priority.index('/mode/vs/0') < priority.index(
|
||||||
|
'/energy/consumption/vs/0'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def test_fac_bora_205_flat_fallback_finds_candidate_but_gate_holds_it_back(
|
async def test_fac_bora_205_flat_fallback_finds_candidate_but_gate_holds_it_back(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
):
|
):
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import cbor2
|
import cbor2
|
||||||
|
|
||||||
|
from custom_components.localthings.registry import subdevices as subdevices_module
|
||||||
from custom_components.localthings.registry.capability import Capability
|
from custom_components.localthings.registry.capability import Capability
|
||||||
from custom_components.localthings.registry.entities import BinarySensorDesc, SensorDesc
|
from custom_components.localthings.registry.entities import BinarySensorDesc, SensorDesc
|
||||||
from custom_components.localthings.registry.subdevices import (
|
from custom_components.localthings.registry.subdevices import (
|
||||||
@@ -318,6 +319,116 @@ def test_enumerate_prefixed_flat_fallback_probe_log_reports_every_href_tried():
|
|||||||
assert probes[f'/{_UUID}/mode/vs/0'] is True
|
assert probes[f'/{_UUID}/mode/vs/0'] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_enumeration_budget_bounds_silent_prefixed_fallback_and_uses_priority(
|
||||||
|
monkeypatch,
|
||||||
|
):
|
||||||
|
"""A firmware that drops unknown prefixed paths cannot stall setup.
|
||||||
|
|
||||||
|
The Collection probe gets the larger blockwise allowance. The preferred
|
||||||
|
live-state href is then attempted first, and the last Property probe is
|
||||||
|
clamped to exactly the time left in the shared enumeration budget.
|
||||||
|
"""
|
||||||
|
class Clock:
|
||||||
|
now = 0.0
|
||||||
|
|
||||||
|
def monotonic(self):
|
||||||
|
return self.now
|
||||||
|
|
||||||
|
class SilentSession:
|
||||||
|
def __init__(self, clock):
|
||||||
|
self.clock = clock
|
||||||
|
self.calls = []
|
||||||
|
|
||||||
|
def get(self, path, timeout=10.0):
|
||||||
|
self.calls.append((tuple(path), timeout))
|
||||||
|
self.clock.now += timeout
|
||||||
|
raise TimeoutError
|
||||||
|
|
||||||
|
def pace(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
clock = Clock()
|
||||||
|
session = SilentSession(clock)
|
||||||
|
monkeypatch.setattr(subdevices_module.time, 'monotonic', clock.monotonic)
|
||||||
|
resources = {
|
||||||
|
'/subdevices/vs/0': {
|
||||||
|
'x.com.samsung.da.subdeviceIdList': [_UUID],
|
||||||
|
},
|
||||||
|
'/power/vs/0': {'power': 'On'},
|
||||||
|
'/mode/vs/0': {'mode': 'Cool'},
|
||||||
|
}
|
||||||
|
|
||||||
|
found, extra = enumerate_subdevices(
|
||||||
|
session,
|
||||||
|
resources,
|
||||||
|
oic_res_links=[],
|
||||||
|
preferred_hrefs=('/mode/vs/0',),
|
||||||
|
time_budget=7.0,
|
||||||
|
collection_timeout=4.0,
|
||||||
|
property_timeout=2.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert found == []
|
||||||
|
assert extra == {}
|
||||||
|
assert clock.now == 7.0
|
||||||
|
assert session.calls == [
|
||||||
|
((_UUID, 'device', '0'), 4.0),
|
||||||
|
((_UUID, 'mode', 'vs', '0'), 2.0),
|
||||||
|
((_UUID, 'power', 'vs', '0'), 1.0),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_enumeration_keeps_preferred_response_found_before_budget_expires(
|
||||||
|
monkeypatch,
|
||||||
|
):
|
||||||
|
"""A useful early response survives later silent probes hitting the cap."""
|
||||||
|
class Clock:
|
||||||
|
now = 0.0
|
||||||
|
|
||||||
|
def monotonic(self):
|
||||||
|
return self.now
|
||||||
|
|
||||||
|
class PartlyResponsiveSession:
|
||||||
|
def __init__(self, clock):
|
||||||
|
self.clock = clock
|
||||||
|
|
||||||
|
def get(self, path, timeout=10.0):
|
||||||
|
if tuple(path) == (_UUID, 'mode', 'vs', '0'):
|
||||||
|
return 0x45, cbor2.dumps({'mode': 'Cool'})
|
||||||
|
self.clock.now += timeout
|
||||||
|
raise TimeoutError
|
||||||
|
|
||||||
|
def pace(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
clock = Clock()
|
||||||
|
monkeypatch.setattr(subdevices_module.time, 'monotonic', clock.monotonic)
|
||||||
|
resources = {
|
||||||
|
'/subdevices/vs/0': {
|
||||||
|
'x.com.samsung.da.subdeviceIdList': [_UUID],
|
||||||
|
},
|
||||||
|
'/power/vs/0': {'power': 'On'},
|
||||||
|
'/mode/vs/0': {'mode': 'Cool'},
|
||||||
|
}
|
||||||
|
|
||||||
|
found, extra = enumerate_subdevices(
|
||||||
|
PartlyResponsiveSession(clock),
|
||||||
|
resources,
|
||||||
|
oic_res_links=[],
|
||||||
|
preferred_hrefs=('/mode/vs/0',),
|
||||||
|
time_budget=7.0,
|
||||||
|
collection_timeout=4.0,
|
||||||
|
property_timeout=2.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert [(subdevice.kind, subdevice.key) for subdevice in found] == [
|
||||||
|
('prefixed', _UUID),
|
||||||
|
]
|
||||||
|
assert found[0].flat_hrefs == ('/mode/vs/0',)
|
||||||
|
assert extra == {f'/{_UUID}/mode/vs/0': {'mode': 'Cool'}}
|
||||||
|
assert clock.now == 7.0
|
||||||
|
|
||||||
|
|
||||||
def test_enumerate_prefixed_flat_fallback_with_no_master_hrefs_to_probe_is_a_no_op():
|
def test_enumerate_prefixed_flat_fallback_with_no_master_hrefs_to_probe_is_a_no_op():
|
||||||
"""The master itself having nothing but /subdevices/vs/0 in its own
|
"""The master itself having nothing but /subdevices/vs/0 in its own
|
||||||
resources this cycle (e.g. a very first, mostly-empty poll) must not
|
resources this cycle (e.g. a very first, mostly-empty poll) must not
|
||||||
|
|||||||
Reference in New Issue
Block a user