Files
localthings/tests/test_subdevices.py
T
Marc Billow 36a642135b Fix pre-existing ty diagnostics in a second batch of test files
Same isinstance/cast narrowing and Optional-field assert pattern as the
prior commits, covering the airconditioner, fridge, washer, operational,
subdevices, sensor_hysteresis, laundry, select_options, identity and
entities test files.
2026-08-03 00:11:05 +00:00

949 lines
36 KiB
Python

"""Tests for registry/subdevices.py -- multi-indoor-subdevice ("composite
device") support, issue #177. See DESIGN-177.md for the two board patterns
(`ARTIK051_DONGLE_FAC_18K`'s indexed siblings, `TP2X_FAC_BORA_21K`'s
UUID-prefixed tree) this module unifies.
"""
from __future__ import annotations
from typing import cast
import cbor2
from custom_components.localthings.registry.by_type import DeviceRegistry
from custom_components.localthings.registry.capability import Capability
from custom_components.localthings.registry.entities import BinarySensorDesc, SensorDesc
from custom_components.localthings.registry.subdevices import (
MAIN,
Subdevice,
canonical_view,
discover_partitioned,
enumerate_subdevices,
normalize_seed_batch,
)
_UUID = "6c2dff6d-ee5c-dad1-6a5e-000000000001"
def _indexed(n: str) -> Subdevice:
return Subdevice(kind="indexed", key=n, seed_path=("device", n))
def _prefixed(sub_id: str) -> Subdevice:
return Subdevice(kind="prefixed", key=sub_id, seed_path=(sub_id, "device", "0"))
# ---------------------------------------------------------------------------
# Subdevice.to_actual / to_canonical / owns / key_prefix
# ---------------------------------------------------------------------------
class TestMainIsIdentity:
def test_to_actual_unchanged(self):
assert MAIN.to_actual("/mode/vs/0") == "/mode/vs/0"
def test_to_canonical_unchanged(self):
assert MAIN.to_canonical("/mode/vs/0") == "/mode/vs/0"
def test_owns_is_always_false(self):
"""MAIN never "owns" a href by this definition -- it gets whatever's
left after every other subdevice's hrefs are excluded (canonical_view)."""
assert MAIN.owns("/mode/vs/0") is False
assert MAIN.owns("/mode/vs/1") is False
def test_key_prefix_is_empty(self):
"""The master's flattened state keys must stay byte-identical
to every device shipped before issue #177 -- see adapter._key."""
assert MAIN.key_prefix == ""
class TestIndexedTransform:
def test_to_actual_rewrites_trailing_zero(self):
subdevice = _indexed("1")
assert subdevice.to_actual("/mode/vs/0") == "/mode/vs/1"
assert subdevice.to_actual("/device/0") == "/device/1"
def test_to_actual_leaves_non_zero_trailing_segment_alone(self):
"""Deliberately not a "replace any trailing digit" rule -- a genuine
multi-instance resource (the fridge's pattern-cap hrefs, e.g.
'/door/vs/1') must not be misread as a subdevice's own href."""
subdevice = _indexed("1")
assert subdevice.to_actual("/door/vs/1") == "/door/vs/1"
def test_to_canonical_round_trips(self):
subdevice = _indexed("1")
assert subdevice.to_canonical(subdevice.to_actual("/mode/vs/0")) == "/mode/vs/0"
def test_to_canonical_rejects_wrong_index(self):
subdevice = _indexed("1")
assert subdevice.to_canonical("/mode/vs/2") is None
def test_to_canonical_rejects_index_zero(self):
"""Index 0 belongs to MAIN, never to an indexed subdevice."""
subdevice = _indexed("1")
assert subdevice.to_canonical("/mode/vs/0") is None
def test_owns(self):
subdevice = _indexed("1")
assert subdevice.owns("/mode/vs/1") is True
assert subdevice.owns("/mode/vs/0") is False
assert subdevice.owns("/mode/vs/2") is False
def test_key_prefix(self):
assert _indexed("1").key_prefix == "subdevice1_"
assert _indexed("2").key_prefix == "subdevice2_"
class TestPrefixedTransform:
def test_to_actual_prepends_id(self):
subdevice = _prefixed(_UUID)
assert subdevice.to_actual("/mode/vs/0") == f"/{_UUID}/mode/vs/0"
def test_to_canonical_round_trips(self):
subdevice = _prefixed(_UUID)
assert subdevice.to_canonical(subdevice.to_actual("/mode/vs/0")) == "/mode/vs/0"
def test_to_canonical_rejects_missing_prefix(self):
subdevice = _prefixed(_UUID)
assert subdevice.to_canonical("/mode/vs/0") is None
def test_to_canonical_requires_path_boundary(self):
"""A different, longer id that merely starts with the same
characters must not be mistaken for this one's own href."""
subdevice = _prefixed(_UUID)
assert subdevice.to_canonical(f"/{_UUID}extra/mode/vs/0") is None
def test_owns(self):
subdevice = _prefixed(_UUID)
assert subdevice.owns(f"/{_UUID}/mode/vs/0") is True
assert subdevice.owns("/mode/vs/0") is False
def test_key_prefix_strips_non_alphanumerics(self):
assert _prefixed(_UUID).key_prefix == "subdevice_6c2dff6dee5cdad16a5e000000000001_"
# ---------------------------------------------------------------------------
# canonical_view
# ---------------------------------------------------------------------------
class TestCanonicalView:
def test_main_with_no_subdevices_is_unchanged(self):
resources = {"/mode/vs/0": {"a": 1}, "/power/vs/0": {"b": 2}}
assert canonical_view(MAIN, resources, []) == resources
def test_main_excludes_subdevice_owned_hrefs(self):
"""A sibling's own /mode/vs/1 must not leak into the master's
canonical /mode/vs/0 view -- otherwise exists_fn/is_legacy_board
checks that scan the whole dict would see two subdevices' state mixed
together under one key."""
resources = {
"/mode/vs/0": {"unit": "main"},
"/mode/vs/1": {"unit": "sibling"},
}
sub1 = _indexed("1")
view = canonical_view(MAIN, resources, [sub1])
assert view == {"/mode/vs/0": {"unit": "main"}}
def test_indexed_view_is_rewritten_to_canonical_hrefs(self):
resources = {
"/mode/vs/0": {"unit": "main"},
"/mode/vs/1": {"unit": "sibling"},
"/power/vs/1": {"p": "On"},
}
sub1 = _indexed("1")
view = canonical_view(sub1, resources, [sub1])
assert view == {
"/mode/vs/0": {"unit": "sibling"},
"/power/vs/0": {"p": "On"},
}
def test_prefixed_view_is_rewritten_to_canonical_hrefs(self):
resources = {
f"/{_UUID}/mode/vs/0": {"unit": "sub"},
"/mode/vs/0": {"unit": "main"},
}
subdevice = _prefixed(_UUID)
view = canonical_view(subdevice, resources, [subdevice])
assert view == {"/mode/vs/0": {"unit": "sub"}}
def test_including_main_in_subdevices_is_harmless(self):
"""MAIN.owns() is always False, so passing the full roster
(including MAIN itself) to canonical_view must not change anything."""
resources = {"/mode/vs/0": {"unit": "main"}, "/mode/vs/1": {"unit": "sib"}}
sub1 = _indexed("1")
assert canonical_view(MAIN, resources, [MAIN, sub1]) == canonical_view(
MAIN, resources, [sub1]
)
# ---------------------------------------------------------------------------
# normalize_seed_batch
# ---------------------------------------------------------------------------
def test_normalize_seed_batch_indexed_is_a_no_op():
subdevice = _indexed("1")
batch = {"/mode/vs/1": {"a": 1}}
assert normalize_seed_batch(subdevice, batch) == batch
def test_normalize_seed_batch_prefixed_adds_missing_prefix():
subdevice = _prefixed(_UUID)
batch = {"/mode/vs/0": {"a": 1}}
assert normalize_seed_batch(subdevice, batch) == {f"/{_UUID}/mode/vs/0": {"a": 1}}
def test_normalize_seed_batch_prefixed_leaves_already_prefixed_alone():
subdevice = _prefixed(_UUID)
batch = {f"/{_UUID}/mode/vs/0": {"a": 1}}
assert normalize_seed_batch(subdevice, batch) == batch
# ---------------------------------------------------------------------------
# enumerate_subdevices
# ---------------------------------------------------------------------------
class _FakeSession:
"""path-tuple -> raw batch list, cbor-encoded on GET -- same shape as
test_identity.py's FakeSession, extended to serve Collection batches
(a CBOR list), not just a bare Property map."""
def __init__(self, table):
self.table = table
def get(self, path, timeout=10.0):
body = self.table.get(tuple(path))
if body is None:
return 0x84, b""
return 0x45, cbor2.dumps(body)
def pace(self):
pass
_DEVCOL_REP = {"rt": ["x.com.samsung.devcol", "oic.wk.col"]}
def test_enumerate_indexed_from_oic_res_links():
oic_res = [
{
"di": "aaaa",
"links": [
{"href": "/device/0"},
{"href": "/device/1"},
{"href": "/device/2"},
],
}
]
sess = _FakeSession(
{
("device", "1"): [_DEVCOL_REP, {"href": "/mode/vs/1", "rep": {"m": 1}}],
("device", "2"): [_DEVCOL_REP, {"href": "/mode/vs/2", "rep": {"m": 2}}],
}
)
subdevices, extra = enumerate_subdevices(sess, {}, oic_res)
assert sorted((u.kind, u.key) for u in subdevices) == [
("indexed", "1"),
("indexed", "2"),
]
assert extra == {"/mode/vs/1": {"m": 1}, "/mode/vs/2": {"m": 2}}
def test_enumerate_indexed_falls_back_to_speculative_probe_when_oic_res_hides_the_tree():
"""A board whose /oic/res doesn't reveal a second logical Device (e.g.
it hides the whole tree, TP2X_FAC_BORA_21K-style) falls back to probing
/device/1 and /device/2 directly -- the same bound the old
identity.py-level probe used."""
sess = _FakeSession(
{
("device", "1"): [_DEVCOL_REP, {"href": "/mode/vs/1", "rep": {"m": 1}}],
# /device/2 not in the table -> 4.04 -> not materialized.
}
)
subdevices, extra = enumerate_subdevices(sess, {}, oic_res_links=[])
assert [(u.kind, u.key) for u in subdevices] == [("indexed", "1")]
assert extra == {"/mode/vs/1": {"m": 1}}
def test_enumerate_indexed_only_materializes_subdevices_with_a_non_empty_batch():
"""Per issue #177's design call: any seed that answers with a non-empty
batch is materialized, but an empty ({}) answer is not -- no separate
"is this subdevice real" gate."""
sess = _FakeSession({}) # neither /device/1 nor /device/2 answers
subdevices, extra = enumerate_subdevices(sess, {}, oic_res_links=[])
assert subdevices == []
assert extra == {}
def test_enumerate_prefixed_from_subdevice_id_list():
resources = {
"/subdevices/vs/0": {"x.com.samsung.da.subdeviceIdList": [_UUID]},
}
sess = _FakeSession(
{
(_UUID, "device", "0"): [
_DEVCOL_REP,
{"href": "/mode/vs/0", "rep": {"m": "cool"}},
],
}
)
subdevices, extra = enumerate_subdevices(sess, resources, oic_res_links=[])
assert [(u.kind, u.key) for u in subdevices] == [("prefixed", _UUID)]
# Batch echoed the bare (unprefixed) href -- normalized to carry the id.
assert extra == {f"/{_UUID}/mode/vs/0": {"m": "cool"}}
def test_enumerate_prefixed_falls_back_to_flat_hrefs_when_device0_collection_is_empty():
"""issue #205: not every prefixed subdevice exposes its own
/<uuid>/device/0 Collection -- not even TP2X_FAC_BORA_21K, the board
this pattern was built against, always does. When it doesn't,
enumeration probes every href the master itself answered this cycle,
individually, under the UUID prefix, and keeps whichever ones answer."""
resources = {
"/subdevices/vs/0": {"x.com.samsung.da.subdeviceIdList": [_UUID]},
"/mode/vs/0": {"m": "cool"},
"/power/vs/0": {"p": "On"},
}
sess = _FakeSession(
{
# (_UUID, 'device', '0') deliberately absent -> Collection probe fails.
(_UUID, "mode", "vs", "0"): {"mode": "cool"},
# (_UUID, 'power', 'vs', '0') deliberately absent -> drops out.
}
)
subdevices, extra = enumerate_subdevices(sess, resources, oic_res_links=[])
assert len(subdevices) == 1
subdevice = subdevices[0]
assert (subdevice.kind, subdevice.key) == ("prefixed", _UUID)
assert subdevice.seed_path == ()
assert subdevice.flat_hrefs == ("/mode/vs/0",)
assert extra == {f"/{_UUID}/mode/vs/0": {"mode": "cool"}}
def test_enumerate_prefixed_flat_fallback_materializes_nothing_when_no_href_answers():
"""Same posture as every other candidate check in this module: nothing
answering means no candidate, not a crash."""
resources = {
"/subdevices/vs/0": {"x.com.samsung.da.subdeviceIdList": [_UUID]},
"/mode/vs/0": {"m": "cool"},
}
subdevices, extra = enumerate_subdevices(_FakeSession({}), resources, oic_res_links=[])
assert subdevices == []
assert extra == {}
def test_enumerate_prefixed_flat_fallback_probe_log_reports_every_href_tried():
resources = {
"/subdevices/vs/0": {"x.com.samsung.da.subdeviceIdList": [_UUID]},
"/mode/vs/0": {"m": "cool"},
}
sess = _FakeSession(
{
(_UUID, "mode", "vs", "0"): {"mode": "cool"},
}
)
probes: dict[str, bool] = {}
enumerate_subdevices(sess, resources, oic_res_links=[], probe_log=probes.__setitem__)
assert probes[f"/{_UUID}/device/0"] is False
assert probes[f"/{_UUID}/mode/vs/0"] is True
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
resources this cycle (e.g. a very first, mostly-empty poll) must not
crash the fallback loop -- the only href in `resources` is
/subdevices/vs/0 itself, which the fake session doesn't answer under
the prefix either, so nothing materializes."""
resources = {
"/subdevices/vs/0": {"x.com.samsung.da.subdeviceIdList": [_UUID]},
}
subdevices, extra = enumerate_subdevices(_FakeSession({}), resources, oic_res_links=[])
assert subdevices == []
assert extra == {}
def test_enumerate_prefixed_flat_fallback_does_not_cross_contaminate_a_second_uuid():
"""Two prefixed candidates in the same subdeviceIdList, one whose
Collection endpoint works and one that needs the flat fallback -- each
must end up with only its own hrefs, no bleed between them."""
uuid_a, uuid_b = _UUID, "11111111-1111-1111-1111-111111111111"
resources = {
"/subdevices/vs/0": {"x.com.samsung.da.subdeviceIdList": [uuid_a, uuid_b]},
"/mode/vs/0": {"m": "cool"},
}
sess = _FakeSession(
{
(uuid_a, "device", "0"): [
_DEVCOL_REP,
{"href": "/mode/vs/0", "rep": {"m": "a-collection"}},
],
# uuid_b's Collection deliberately absent -> falls back to the flat
# per-href probe.
(uuid_b, "mode", "vs", "0"): {"m": "b-flat"},
}
)
subdevices, extra = enumerate_subdevices(sess, resources, oic_res_links=[])
by_key = {u.key: u for u in subdevices}
assert set(by_key) == {uuid_a, uuid_b}
assert by_key[uuid_a].seed_path == (uuid_a, "device", "0")
assert by_key[uuid_a].flat_hrefs == ()
assert by_key[uuid_b].seed_path == ()
assert by_key[uuid_b].flat_hrefs == ("/mode/vs/0",)
assert extra == {
f"/{uuid_a}/mode/vs/0": {"m": "a-collection"},
f"/{uuid_b}/mode/vs/0": {"m": "b-flat"},
}
def test_enumerate_prefixed_tolerates_redacted_string_id_list():
"""subdeviceIdList matches redact.py's 'deviceid' substring rule, and the
real airconditioner_fac_bora_device.json fixture carries the literal
redacted string there -- this must yield zero subdevices, not crash."""
resources = {
"/subdevices/vs/0": {"x.com.samsung.da.subdeviceIdList": "REDACTED"},
}
subdevices, extra = enumerate_subdevices(_FakeSession({}), resources, oic_res_links=[])
assert subdevices == []
assert extra == {}
def test_enumerate_no_subdevices_resource_at_all():
subdevices, extra = enumerate_subdevices(_FakeSession({}), {}, oic_res_links=[])
assert subdevices == []
assert extra == {}
def test_enumerate_indexed_materializes_candidate_regardless_of_content():
"""enumerate_subdevices itself has no way to tell a real sibling from an
unused slot that merely answers the same shape (the reporter's
/device/2) -- that's discover_partitioned's job (see its own tests
below). A same-shaped batch of otherwise-empty reps is still returned
as a candidate here."""
oic_res = [{"di": "a", "links": [{"href": "/device/2"}]}]
sess = _FakeSession(
{
("device", "2"): [
_DEVCOL_REP,
{"href": "/power/vs/2", "rep": {}},
{"href": "/configuration/vs/2", "rep": {"region": "123"}},
],
}
)
subdevices, extra = enumerate_subdevices(sess, {}, oic_res)
assert [(u.kind, u.key) for u in subdevices] == [("indexed", "2")]
assert extra == {"/power/vs/2": {}, "/configuration/vs/2": {"region": "123"}}
def test_enumerate_probe_log_reports_every_attempt():
oic_res = [{"di": "aaaa", "links": [{"href": "/device/1"}]}]
sess = _FakeSession(
{
("device", "1"): [_DEVCOL_REP, {"href": "/mode/vs/1", "rep": {"m": 1}}],
}
)
probes: dict[str, bool] = {}
enumerate_subdevices(sess, {}, oic_res, probe_log=probes.__setitem__)
# /multidevice/vs/0 is always probed too (issue #177 follow-up) -- not
# in this session's table, so it reports "checked, nothing there".
assert probes == {"/device/1": True, "/multidevice/vs/0": False}
def test_enumerate_probe_log_reports_empty_answers_too():
"""Distinguishes "checked, nothing there" from "never checked" (the
posture the speculative-probe code this replaced used to document
directly in identity.py)."""
probes: dict[str, bool] = {}
enumerate_subdevices(_FakeSession({}), {}, oic_res_links=[], probe_log=probes.__setitem__)
assert probes == {
"/device/1": False,
"/device/2": False,
"/multidevice/vs/0": False,
}
def test_enumerate_multidevice_vs_0_is_captured_for_diagnostics_not_a_gate():
"""/multidevice/vs/0 (issue #177 follow-up) is corroborating evidence
only -- captured into the merged resources so diagnostics can report
it, never consulted by the liveness gate itself."""
sess = _FakeSession(
{
("multidevice", "vs", "0"): {"x.com.samsung.da.numofsubdevice": "2"},
}
)
subdevices, extra = enumerate_subdevices(sess, {}, oic_res_links=[])
assert subdevices == [] # no /device/<n> or subdevice id answered
assert extra == {
"/multidevice/vs/0": {"x.com.samsung.da.numofsubdevice": "2"},
}
def test_enumerate_both_patterns_checked_independently():
"""No board needs disambiguation logic -- enumeration just checks both
signals and takes whatever answers."""
resources = {
"/subdevices/vs/0": {"x.com.samsung.da.subdeviceIdList": [_UUID]},
}
oic_res = [{"di": "a", "links": [{"href": "/device/1"}]}]
sess = _FakeSession(
{
(_UUID, "device", "0"): [_DEVCOL_REP, {"href": "/mode/vs/0", "rep": {"m": 1}}],
("device", "1"): [_DEVCOL_REP, {"href": "/mode/vs/1", "rep": {"m": 2}}],
}
)
subdevices, _extra = enumerate_subdevices(sess, resources, oic_res)
assert sorted((u.kind, u.key) for u in subdevices) == [
("indexed", "1"),
("prefixed", _UUID),
]
def test_enumerate_prefixed_id_named_by_both_subdevice_id_list_and_oic_res_is_not_duplicated():
"""A board can carry a UUID that is both listed in subdeviceIdList
(Pattern B's signal) *and* advertised as an /oic/res link prefix
(Pattern C's) -- the TP2X_FAC_BORA_21K reporter's own board does this.
The two signals must resolve to one candidate, not two."""
resources = {
"/subdevices/vs/0": {"x.com.samsung.da.subdeviceIdList": [_UUID]},
}
oic_res = [
{
"di": "a",
"links": [
{"href": f"/{_UUID}/multidevice/vs/0"},
],
}
]
sess = _FakeSession(
{
(_UUID, "device", "0"): [_DEVCOL_REP, {"href": "/mode/vs/0", "rep": {"m": 1}}],
}
)
subdevices, _extra = enumerate_subdevices(sess, resources, oic_res)
assert [(u.kind, u.key) for u in subdevices] == [("prefixed", _UUID)]
def test_enumerate_prefixed_id_case_mismatch_between_sources_is_not_duplicated():
"""subdeviceIdList and an /oic/res link prefix can name the same UUID
with different casing -- the match must be case-insensitive, or the
same physical subdevice materializes twice under two different keys."""
upper_uuid = _UUID.upper()
resources = {
"/subdevices/vs/0": {"x.com.samsung.da.subdeviceIdList": [upper_uuid]},
}
oic_res = [
{
"di": "a",
"links": [
{"href": f"/{_UUID}/multidevice/vs/0"},
],
}
]
sess = _FakeSession(
{
(upper_uuid, "device", "0"): [_DEVCOL_REP, {"href": "/mode/vs/0", "rep": {"m": 1}}],
(_UUID, "device", "0"): [_DEVCOL_REP, {"href": "/mode/vs/0", "rep": {"m": 1}}],
}
)
subdevices, _extra = enumerate_subdevices(sess, resources, oic_res)
assert len(subdevices) == 1
assert subdevices[0].kind == "prefixed"
assert subdevices[0].key.lower() == _UUID
# ---------------------------------------------------------------------------
# discover_partitioned
# ---------------------------------------------------------------------------
class _FakeRegistry:
def __init__(self, name, capabilities, pattern_capabilities=()):
self.name = name
self.capabilities = capabilities
self.pattern_capabilities = list(pattern_capabilities)
def test_discover_partitioned_binds_main_and_subdevice_separately():
mode_cap = Capability(
href="/mode/vs/0",
entities=(BinarySensorDesc(key="mode", field="m"),),
)
registry = {"/mode/vs/0": [mode_cap]}
reg = _FakeRegistry("airconditioner", registry)
sub1 = _indexed("1")
resources = {
"/mode/vs/0": {"m": "main"},
"/mode/vs/1": {"m": "sibling"},
}
def resolve(_resources, **_kwargs):
return reg
bound, device_type_name, materialized, skipped = discover_partitioned(
resources,
[sub1],
resolve,
fallback_capabilities={},
)
assert device_type_name == "airconditioner"
assert materialized == [sub1]
assert skipped == []
by_href = {b.href: b for b in bound}
assert set(by_href) == {"/mode/vs/0", "/mode/vs/1"}
assert by_href["/mode/vs/0"].subdevice == MAIN
assert by_href["/mode/vs/1"].subdevice == sub1
def test_discover_partitioned_main_pass_excludes_subdevice_hrefs_from_unbound():
"""A subdevice's own /mode/vs/1 must not land in the main pass's unbound
list -- otherwise it raises a spurious coverage-gap repair for a href
nothing in the main registry claims literally, even though the *same*
canonical /mode/vs/0 href is properly claimed for both subdevices.
A registry that binds /mode/vs/0 (so both the main pass and the
subdevice pass over their own canonical views have something to claim
it) makes this unambiguous: if partitioning were broken -- e.g. the
main pass iterating unfiltered `resources` instead of `main_view` --
/mode/vs/1 would show up in `unbound` because nothing in the registry
is keyed on the literal string '/mode/vs/1'. With correct partitioning
the main pass never sees that href at all (canonical_view excludes it),
and the subdevice pass resolves it via its own canonical '/mode/vs/0'."""
cap = Capability(href="/mode/vs/0", entities=(BinarySensorDesc(key="mode", field="m"),))
reg = _FakeRegistry("airconditioner", {"/mode/vs/0": [cap]})
sub1 = _indexed("1")
resources = {
"/mode/vs/0": {"m": "main"},
"/mode/vs/1": {"m": "sibling"},
}
unbound = []
discover_partitioned(
resources,
[sub1],
lambda r, **_: cast(DeviceRegistry, reg),
fallback_capabilities={},
log=unbound.append,
)
assert unbound == []
def test_discover_partitioned_subdevice_resolves_its_own_registry():
"""A subdevice reporting its own /information/vs/0 resolves its own
device type (issue #177's real wall subdevice: TP2X_FAC_BORA_RAC_21K ->
RAC -> airconditioner) independent of the master's."""
main_cap = Capability(href="/mode/vs/0", entities=(BinarySensorDesc(key="m", field="x"),))
sub_cap = Capability(href="/mode/vs/0", entities=(BinarySensorDesc(key="m2", field="x"),))
main_reg = _FakeRegistry("main_type", {"/mode/vs/0": [main_cap]})
sub_reg = _FakeRegistry("sub_type", {"/mode/vs/0": [sub_cap]})
sub1 = _indexed("1")
resources = {"/mode/vs/0": {"x": 1}, "/mode/vs/1": {"x": 2}}
def resolve(view, **_kwargs):
# The subdevice's canonical view is exactly {'/mode/vs/0': {'x': 2}}.
return sub_reg if view.get("/mode/vs/0", {}).get("x") == 2 else main_reg
bound, device_type_name, materialized, skipped = discover_partitioned(
resources,
[sub1],
resolve,
fallback_capabilities={},
)
assert device_type_name == "main_type"
assert materialized == [sub1]
assert skipped == []
keys = {(b.href, b.desc.key) for b in bound}
assert ("/mode/vs/0", "m") in keys
assert ("/mode/vs/1", "m2") in keys
def test_discover_partitioned_subdevice_falls_back_to_master_registry():
"""A subdevice that reports no identity of its own resolves through the
master's registry instead of the global fallback -- the master itself
must actually resolve here (it reports /information/vs/0), or there is
no master registry for the fallback to reach for."""
cap = Capability(href="/mode/vs/0", entities=(BinarySensorDesc(key="m", field="x"),))
main_reg = _FakeRegistry("airconditioner", {"/mode/vs/0": [cap]})
sub1 = _indexed("1")
resources = {
"/information/vs/0": {"model": "main"},
"/mode/vs/0": {"x": 1},
"/mode/vs/1": {"x": 2},
}
def resolve(view, **_kwargs):
return main_reg if view.get("/information/vs/0") else None
bound, _, materialized, skipped = discover_partitioned(
resources,
[sub1],
resolve,
fallback_capabilities={},
)
assert materialized == [sub1]
assert skipped == []
hrefs = {b.href for b in bound}
assert "/mode/vs/1" in hrefs
def test_discover_partitioned_oic_device_types_resolves_main_pass():
"""`oic_device_types` reaches the main-pass resolve call as
`device_types=`, letting a fake `resolve_registry` mirror the real
resolve()'s precedence between /oic/d and model-based detection."""
cap = Capability(href="/mode/vs/0", entities=(BinarySensorDesc(key="m", field="x"),))
oic_reg = _FakeRegistry("washer", {"/mode/vs/0": [cap]})
resources = {"/mode/vs/0": {"x": 1}}
def resolve(_resources, device_types=()):
return oic_reg if "oic.d.washer" in device_types else None
bound, device_type_name, _, _ = discover_partitioned(
resources,
[],
resolve,
fallback_capabilities={},
oic_device_types=("oic.d.washer",),
)
assert device_type_name == "washer"
assert {b.href for b in bound} == {"/mode/vs/0"}
def test_discover_partitioned_oic_device_types_not_applied_to_subdevices():
"""The master's own /oic/d type must not leak into a subdevice's own
resolution -- only main_view's resolve() call receives it, so a fake
resolver keyed purely on device_types resolves nothing for the
subdevice pass and it falls back to the master's registry, per the
documented (and unchanged) subdevice-fallback behavior."""
cap = Capability(href="/mode/vs/0", entities=(BinarySensorDesc(key="m", field="x"),))
oic_reg = _FakeRegistry("washer", {"/mode/vs/0": [cap]})
sub1 = _indexed("1")
resources = {"/mode/vs/0": {"x": 1}, "/mode/vs/1": {"x": 2}}
def resolve(_resources, device_types=()):
return oic_reg if "oic.d.washer" in device_types else None
bound, device_type_name, materialized, skipped = discover_partitioned(
resources,
[sub1],
resolve,
fallback_capabilities={},
oic_device_types=("oic.d.washer",),
)
assert device_type_name == "washer"
assert materialized == [sub1]
assert skipped == []
# The subdevice's own resolve() call sees no device_types and returns
# None, falling back to the master's oic_reg -- same capabilities, so
# /mode/vs/1 still binds via that shared registry.
assert {b.href for b in bound} == {"/mode/vs/0", "/mode/vs/1"}
def test_discover_partitioned_no_subdevices_matches_plain_discover():
"""For a device with no subdevices this must be exactly the single
discover() call it replaces -- the hard regression guard the whole
design exists to protect."""
from custom_components.localthings.registry.discovery import discover
cap = Capability(href="/mode/vs/0", entities=(BinarySensorDesc(key="m", field="x"),))
reg = _FakeRegistry("airconditioner", {"/mode/vs/0": [cap]})
resources = {"/mode/vs/0": {"x": 1}}
bound_via_helper, _, materialized, skipped = discover_partitioned(
resources,
[],
lambda r, **_: cast(DeviceRegistry, reg),
fallback_capabilities={},
)
bound_direct = discover(resources, reg.capabilities, reg.pattern_capabilities)
assert bound_via_helper == bound_direct
assert materialized == []
assert skipped == []
# ---------------------------------------------------------------------------
# discover_partitioned's materialization gate: a candidate is only kept if
# it produced at least one *primary* (no entity_category) bound entity whose
# flattened value isn't None. This is what tells the reporter's real
# /device/1 sibling apart from the unused /device/2 slot that answers the
# same shape.
# ---------------------------------------------------------------------------
def test_discover_partitioned_skips_candidate_with_no_live_primary_entity():
"""A candidate whose only populated entity is diagnostic-category
doesn't count -- exactly the reporter's /device/2 shape (an alarm_code
sensor reading something even though the subdevice itself is empty)."""
diag_cap = Capability(
href="/alarms/vs/0",
entities=(SensorDesc(key="alarm_code", field="code", entity_category="diagnostic"),),
)
reg = _FakeRegistry("airconditioner", {"/alarms/vs/0": [diag_cap]})
unit2 = _indexed("2")
resources = {
"/alarms/vs/0": {"code": "main-alarm"},
"/alarms/vs/2": {"code": "ErrorCode_OFF"}, # populated, but diagnostic-only
}
bound, _, materialized, skipped = discover_partitioned(
resources,
[unit2],
lambda r, **_: cast(DeviceRegistry, reg),
fallback_capabilities={},
)
assert materialized == []
assert len(skipped) == 1
assert skipped[0].subdevice == unit2
assert skipped[0].hrefs == ("/alarms/vs/2",)
# The candidate contributes nothing at all -- not even its diagnostic
# entity -- once skipped.
assert all(b.subdevice != unit2 for b in bound)
def test_discover_partitioned_materializes_candidate_with_live_primary_entity():
"""A candidate with a populated *primary* (no entity_category) entity
is materialized, even alongside an all-empty diagnostic sibling href --
exactly the reporter's /device/1 shape."""
climate_cap = Capability(
href="/mode/vs/0",
entities=(BinarySensorDesc(key="mode", field="m"),), # no entity_category -> primary
)
diag_cap = Capability(
href="/alarms/vs/0",
entities=(SensorDesc(key="alarm_code", field="code", entity_category="diagnostic"),),
)
reg = _FakeRegistry(
"airconditioner",
{"/mode/vs/0": [climate_cap], "/alarms/vs/0": [diag_cap]},
)
sub1 = _indexed("1")
resources = {
"/mode/vs/0": {"m": "main"},
"/alarms/vs/0": {"code": "main-alarm"},
"/mode/vs/1": {"m": "Cool"},
"/alarms/vs/1": {}, # empty -- not what makes this subdevice live
}
bound, _, materialized, skipped = discover_partitioned(
resources,
[sub1],
lambda r, **_: cast(DeviceRegistry, reg),
fallback_capabilities={},
)
assert materialized == [sub1]
assert skipped == []
hrefs = {b.href for b in bound if b.subdevice == sub1}
assert hrefs == {"/mode/vs/1", "/alarms/vs/1"}
def test_discover_partitioned_skips_candidate_whose_only_live_primary_is_a_meter():
"""A cumulative meter doesn't count as evidence either (issue #214) --
the reporter's non-composite ARTIK051_KRAC_18K answers /device/1 with
every operational rep empty {} plus a populated /energy/consumption/vs/1,
and that lifetime kWh total was enough to materialize a phantom second
air conditioner. It's the appliance's own counter, not proof a second
indoor unit is installed at that slot."""
energy_cap = Capability(
href="/energy/consumption/vs/0",
entities=(
SensorDesc(
key="energy_kwh", field="kwh", device_class="energy", state_class="total_increasing"
),
), # primary, but a meter
)
climate_cap = Capability(
href="/mode/vs/0",
entities=(BinarySensorDesc(key="mode", field="m"),),
)
reg = _FakeRegistry(
"airconditioner",
{"/energy/consumption/vs/0": [energy_cap], "/mode/vs/0": [climate_cap]},
)
unit1 = _indexed("1")
resources = {
"/energy/consumption/vs/0": {"kwh": 1175.2},
"/mode/vs/0": {"m": "Cool"},
"/energy/consumption/vs/1": {"kwh": 1175.2}, # populated, but a meter
"/mode/vs/1": {}, # the slot's real state: empty
}
bound, _, materialized, skipped = discover_partitioned(
resources,
[unit1],
lambda r, **_: cast(DeviceRegistry, reg),
fallback_capabilities={},
)
assert materialized == []
assert [s.subdevice for s in skipped] == [unit1]
assert all(b.subdevice != unit1 for b in bound)
def test_discover_partitioned_meter_carve_out_does_not_gate_out_a_live_subdevice():
"""The carve-out removes one *kind* of evidence, not the subdevice: a
candidate reporting its own operational state alongside a meter still
materializes, and still gets its meter entity once it does."""
energy_cap = Capability(
href="/energy/consumption/vs/0",
entities=(
SensorDesc(
key="energy_kwh", field="kwh", device_class="energy", state_class="total_increasing"
),
),
)
climate_cap = Capability(
href="/mode/vs/0",
entities=(BinarySensorDesc(key="mode", field="m"),),
)
reg = _FakeRegistry(
"airconditioner",
{"/energy/consumption/vs/0": [energy_cap], "/mode/vs/0": [climate_cap]},
)
unit1 = _indexed("1")
resources = {
"/energy/consumption/vs/0": {"kwh": 1175.2},
"/mode/vs/0": {"m": "Auto"},
"/energy/consumption/vs/1": {"kwh": 1175.2},
"/mode/vs/1": {"m": "Cool"}, # its own state -- this is what counts
}
bound, _, materialized, skipped = discover_partitioned(
resources,
[unit1],
lambda r, **_: cast(DeviceRegistry, reg),
fallback_capabilities={},
)
assert materialized == [unit1]
assert skipped == []
assert {b.href for b in bound if b.subdevice == unit1} == {
"/mode/vs/1",
"/energy/consumption/vs/1",
}
def test_discover_partitioned_skipped_candidate_contributes_no_hot_warm_hrefs():
"""A skipped candidate's hrefs must not appear via tier_log either --
'no entities, no hot/warm hrefs' (the skip is total, not just
entity-level)."""
cap = Capability(
href="/mode/vs/0",
poll_tier="warm",
entities=(SensorDesc(key="alarm_code", field="code", entity_category="diagnostic"),),
)
reg = _FakeRegistry("airconditioner", {"/mode/vs/0": [cap]})
unit2 = _indexed("2")
resources = {"/mode/vs/0": {"code": "x"}, "/mode/vs/2": {"code": "y"}}
tiers = []
discover_partitioned(
resources,
[unit2],
lambda r, **_: cast(DeviceRegistry, reg),
fallback_capabilities={},
tier_log=lambda href, tier: tiers.append(href),
)
assert "/mode/vs/2" not in tiers