Files
localthings/tests/test_subdevices.py
T
Marc Billow 5e86f147d4 fix(subdevices): don't materialize a slot whose only live state is a meter
Issue #214: a single-split ARTIK051_KRAC_18K showed up in HA as two air
conditioners. Its /device/1 answers the same unused-slot shape the Pattern A
reporter's /device/2 does -- every operational rep empty {} -- but also
reports a populated /energy/consumption/vs/1. Running discovery against the
reporter's own quoted subdevice block reproduces their diagnostics exactly
(21 bound entities, the same six hrefs), and of those 21 the only primary
entity with a non-None value is energy_kwh: a lifetime kWh total was the
sole thing passing discover_partitioned's liveness gate and materializing
the phantom.

A single-split AC has one compressor and one energy meter, so a
whole-appliance running total appearing under a second index is the
appliance's own bookkeeping, not evidence that hardware is installed at that
slot. Exclude cumulative meters (HA's total/total_increasing state classes,
plus the energy/water/gas device classes for the descriptors that
deliberately declare no state class) from the gate. The gate never applies
to MAIN, and across the whole fixture corpus every device has at least one
non-meter live primary, so no existing device's entities change -- verified
by the golden for the new fixture being identical to the plain KRAC one.

Also implement async_remove_config_entry_device. A subdevice's HA device
outlives the discovery that created it, so a phantom materialized by an
earlier release stays in the registry with no way to delete it from the UI
-- which is the state the second reporter on that issue is in, with a
refrigerator whose diagnostics now report no subdevices at all. Devices the
entry currently provides still refuse removal. No automatic pruning:
enumeration is one-shot and a real sibling can miss a poll (issue #205 on
the reference hardware), so auto-removal would discard a live subdevice's
name, area and automations on a transient miss.

The new fixture's /device/1 seed is the reporter's verbatim capture; its
master half comes from the corpus's other KRAC unit, with the deviations
spelled out in seeds_note.

Claude-Session: https://claude.ai/code/session_01HzUMLnSWBT64o4BQkVEzXp
2026-07-30 18:23:47 +00:00

755 lines
31 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
import cbor2
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),
]
# ---------------------------------------------------------------------------
# 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):
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: 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):
# 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):
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_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: 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: 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: 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: 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: 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: reg, fallback_capabilities={},
tier_log=lambda href, tier: tiers.append(href),
)
assert '/mode/vs/2' not in tiers