rename: unit/sub-unit -> subdevice, matching OCF terminology
"Unit"/"sub-unit" from #199's multi-indoor-device support wasn't OCF idiomatic -- OCF calls each component of a composite device a "subdevice" (see subdeviceIdList), so rename SubUnit -> Subdevice throughout: the registry module, coordinator state, BoundEntity's subdevice field, diagnostics keys (subdevices/subdevices_skipped/ subdevice_probes), entity unique_id prefixes (unit1_/sub_<uuid>_ -> subdevice1_/subdevice_<uuid>_), device-name fallback labels, golden fixtures, tests, README, and the adding-device-support skill. Breaking change to entity unique_ids and diagnostics keys, acceptable since this hasn't been released yet.
This commit is contained in:
32 files changed
+676
-659
No files matched your search
+12
-12
@@ -18,7 +18,7 @@ def _load_device(name: str) -> dict[str, dict]:
|
||||
|
||||
def _load_device_full(name: str):
|
||||
"""Like _load_device, but also returns the optional `oic_res`/`seeds`
|
||||
keys a sub-unit-capable fixture (issue #177) may carry alongside
|
||||
keys a subdevice-capable fixture (issue #177) may carry alongside
|
||||
`device0` -- see the two `airconditioner_*` fixtures with a
|
||||
`seeds_note` field. `oic_res`/`seeds` default to `[]`/`{}` for every
|
||||
other fixture, so this is safe to call on any fixture in the corpus.
|
||||
@@ -44,9 +44,9 @@ class FakeCoapSession:
|
||||
fixture's `seeds` map (raw device0-batch-shaped lists keyed by seed
|
||||
href -- plus any `probes` entries, which are plain Property maps rather
|
||||
than batch lists; both are just CBOR bodies at this layer, and the two
|
||||
readers in registry.subunits already type-check what they get back).
|
||||
Enough surface for registry.subunits.enumerate_sub_units and
|
||||
LocalThingsCoordinator's blocking sub-unit polls to run against fixture
|
||||
readers in registry.subdevices already type-check what they get back).
|
||||
Enough surface for registry.subdevices.enumerate_subdevices and
|
||||
LocalThingsCoordinator's blocking subdevice polls to run against fixture
|
||||
data without a live device -- same idea as test_identity.py's
|
||||
FakeSession, but keyed by href string (post path-join) rather than a
|
||||
path tuple, since callers here pass a `seed_path` tuple straight
|
||||
@@ -69,16 +69,16 @@ class FakeCoapSession:
|
||||
|
||||
|
||||
def _discover_full(resources: dict[str, dict], oic_res, seeds: dict[str, list]):
|
||||
"""Run the *whole* sub-unit-aware discovery pipeline against fixture
|
||||
"""Run the *whole* subdevice-aware discovery pipeline against fixture
|
||||
data, HA-free -- mirrors exactly what LocalThingsCoordinator does across
|
||||
_enumerate_sub_units_blocking + _run_discovery (issue #177), so a test
|
||||
_enumerate_subdevices_blocking + _run_discovery (issue #177), so a test
|
||||
exercising this exercises the real code path, not a re-implementation of
|
||||
it. See the adding-device-support skill's section 2 for the plain
|
||||
(non-sub-unit) equivalent this extends.
|
||||
(non-subdevice) equivalent this extends.
|
||||
|
||||
Returns `(bound, materialized, skipped, full_resources, device_type_name)`:
|
||||
- `bound`: every BoundEntity, main + every materialized sub-unit.
|
||||
- `materialized`/`skipped`: SubUnit / SkippedSubUnit lists straight from
|
||||
- `bound`: every BoundEntity, main + every materialized subdevice.
|
||||
- `materialized`/`skipped`: Subdevice / SkippedSubdevice lists straight from
|
||||
discover_partitioned.
|
||||
- `full_resources`: `resources` merged with every candidate's seed data
|
||||
(actual hrefs) -- what a coordinator's cache would hold.
|
||||
@@ -86,12 +86,12 @@ def _discover_full(resources: dict[str, dict], oic_res, seeds: dict[str, list]):
|
||||
"""
|
||||
from custom_components.localthings.registry.by_type import resolve
|
||||
from custom_components.localthings.registry.registry import CAPABILITIES
|
||||
from custom_components.localthings.registry.subunits import (
|
||||
discover_partitioned, enumerate_sub_units,
|
||||
from custom_components.localthings.registry.subdevices import (
|
||||
discover_partitioned, enumerate_subdevices,
|
||||
)
|
||||
|
||||
sess = FakeCoapSession(seeds)
|
||||
candidates, extra = enumerate_sub_units(sess, resources, oic_res)
|
||||
candidates, extra = enumerate_subdevices(sess, resources, oic_res)
|
||||
full_resources = {**resources, **extra}
|
||||
bound, device_type_name, materialized, skipped = discover_partitioned(
|
||||
full_resources, candidates, resolve, CAPABILITIES,
|
||||
|
||||
@@ -2029,5 +2029,5 @@
|
||||
"x.com.samsung.da.numofsubdevice": "2"
|
||||
}
|
||||
},
|
||||
"seeds_note": "ALL REAL CAPTURED DATA -- issue #177, HJcom's ARTIK051_DONGLE_FAC_18K (Samsung 2-in-1: floor-standing master + wall-mounted second indoor unit). device0, oic_res and both /device/<n> seeds come verbatim from their v0.16.0 diagnostics download, which is the first release that probes /device/1 and /device/2. Nothing here is reconstructed. The reporter masked a handful of values in their own dump before posting (serialNum, and the airflow `direction` / vendor `humidity` readings); those are normalized onto this repo's usual **REDACTED** marker. /device/1 is the real second unit -- populated power/mode/temperature and its own /information/vs/1 reporting ARTIK051_DONGLE_FAC_RAC_18K (RAC = the wall-mounted unit) against the master's ARTIK051_DONGLE_FAC_18K. /device/2 answers with the same 14-href shape but every state rep is empty {} -- it is the unused third slot SmartThings shows disabled, and it is why sub-unit materialization gates on populated climate state rather than on a seed merely answering. `probes` is not part of any batch response: /multidevice/vs/0 is listed in oic_res but absent from /device/0's batch, and the reporter read it by hand through the debug panel (a write attempt returned CoAP 4.00, i.e. read-only). Its numofsubdevice=2 independently corroborates 2 real units, not 3."
|
||||
"seeds_note": "ALL REAL CAPTURED DATA -- issue #177, HJcom's ARTIK051_DONGLE_FAC_18K (Samsung 2-in-1: floor-standing master + wall-mounted second indoor subdevice). device0, oic_res and both /device/<n> seeds come verbatim from their v0.16.0 diagnostics download, which is the first release that probes /device/1 and /device/2. Nothing here is reconstructed. The reporter masked a handful of values in their own dump before posting (serialNum, and the airflow `direction` / vendor `humidity` readings); those are normalized onto this repo's usual **REDACTED** marker. /device/1 is the real second subdevice -- populated power/mode/temperature and its own /information/vs/1 reporting ARTIK051_DONGLE_FAC_RAC_18K (RAC = the wall-mounted subdevice) against the master's ARTIK051_DONGLE_FAC_18K. /device/2 answers with the same 14-href shape but every state rep is empty {} -- it is the unused third slot SmartThings shows disabled, and it is why subdevice materialization gates on populated climate state rather than on a seed merely answering. `probes` is not part of any batch response: /multidevice/vs/0 is listed in oic_res but absent from /device/0's batch, and the reporter read it by hand through the debug panel (a write attempt returned CoAP 4.00, i.e. read-only). Its numofsubdevice=2 independently corroborates 2 real subdevices, not 3."
|
||||
}
|
||||
+2
-2
@@ -727,7 +727,7 @@
|
||||
"rep": {
|
||||
"x.com.samsung.da.modelNum": "TP2X_FAC_BORA_RAC_21K|10233041|600001110015110006000C1200830000",
|
||||
"x.com.samsung.da.description": "TP2X_FAC_BORA_RAC_21K",
|
||||
"x.com.samsung.da.serialNum": "TEST-SUBUNIT-SERIAL-0000"
|
||||
"x.com.samsung.da.serialNum": "TEST-SUBDEVICE-SERIAL-0000"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -805,5 +805,5 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"seeds_note": "device0 and oic_res are real, captured from jhkwon19's diagnostics dump for the TP2X_FAC_BORA_21K board (issue #177, the same physical unit as tests/fixtures/airconditioner_fac_bora_device.json -- that fixture's redacted x.com.samsung.da.subdeviceIdList string is a deliberate, separate regression case and is NOT changed here). subdeviceIdList above is restored to the real ['6c2dff6d-ee5c-dad1-6a5e-000000000001'] reported in the issue thread (redacted in the raw capture the same way serialNum/otnDUID/deviceId are -- it matches redact.py's 'deviceid' substring rule, not because it's actually account data). The seed's /information/vs/0 rep is REAL, verbatim from the reporter's live debug-panel read of /6c2dff6d-.../information/vs/0 (serial replaced with a placeholder) -- modelNum TP2X_FAC_BORA_RAC_21K confirms this is the wall-mounted room unit, distinct from the master's floor-unit TP2X_FAC_BORA_21K. Every other href in this seed batch is CONSTRUCTED (never read from this unit) so the sub-unit's climate entity has a resource surface to bind -- per DESIGN-177.md section 4, retrieval for this pattern is batch-only, and confirming '/6c2dff6d-.../device/0' actually returns a full batch (as opposed to just answering the information probe the reporter tried by hand) is listed as a follow-up, not something this fixture can attest to."
|
||||
"seeds_note": "device0 and oic_res are real, captured from jhkwon19's diagnostics dump for the TP2X_FAC_BORA_21K board (issue #177, the same physical device as tests/fixtures/airconditioner_fac_bora_device.json -- that fixture's redacted x.com.samsung.da.subdeviceIdList string is a deliberate, separate regression case and is NOT changed here). subdeviceIdList above is restored to the real ['6c2dff6d-ee5c-dad1-6a5e-000000000001'] reported in the issue thread (redacted in the raw capture the same way serialNum/otnDUID/deviceId are -- it matches redact.py's 'deviceid' substring rule, not because it's actually account data). The seed's /information/vs/0 rep is REAL, verbatim from the reporter's live debug-panel read of /6c2dff6d-.../information/vs/0 (serial replaced with a placeholder) -- modelNum TP2X_FAC_BORA_RAC_21K confirms this is the wall-mounted room subdevice, distinct from the master's floor subdevice TP2X_FAC_BORA_21K. Every other href in this seed batch is CONSTRUCTED (never read from this subdevice) so the subdevice's climate entity has a resource surface to bind -- per DESIGN-177.md section 4, retrieval for this pattern is batch-only, and confirming '/6c2dff6d-.../device/0' actually returns a full batch (as opposed to just answering the information probe the reporter tried by hand) is listed as a follow-up, not something this fixture can attest to."
|
||||
}
|
||||
@@ -16,12 +16,12 @@
|
||||
"power_energy_kwh",
|
||||
"power_watts",
|
||||
"super_fine_dust",
|
||||
"unit1_alarm_code",
|
||||
"unit1_auto_clean_legacy",
|
||||
"unit1_beep",
|
||||
"unit1_climate",
|
||||
"unit1_current_temperature_c",
|
||||
"unit1_good_sleep",
|
||||
"unit1_humidity"
|
||||
"subdevice1_alarm_code",
|
||||
"subdevice1_auto_clean_legacy",
|
||||
"subdevice1_beep",
|
||||
"subdevice1_climate",
|
||||
"subdevice1_current_temperature_c",
|
||||
"subdevice1_good_sleep",
|
||||
"subdevice1_humidity"
|
||||
]
|
||||
}
|
||||
+2
-2
@@ -15,8 +15,8 @@
|
||||
"humidity",
|
||||
"power_energy_kwh",
|
||||
"power_watts",
|
||||
"sub_6c2dff6dee5cdad16a5e000000000001_climate",
|
||||
"sub_6c2dff6dee5cdad16a5e000000000001_current_temperature_c",
|
||||
"subdevice_6c2dff6dee5cdad16a5e000000000001_climate",
|
||||
"subdevice_6c2dff6dee5cdad16a5e000000000001_current_temperature_c",
|
||||
"tropical_night_mode"
|
||||
]
|
||||
}
|
||||
@@ -20,9 +20,9 @@ class _FakeCoordinator:
|
||||
def resource(self, href):
|
||||
return self.last_resources.get(href, {})
|
||||
|
||||
def canonical_resources(self, sub_unit):
|
||||
def canonical_resources(self, subdevice):
|
||||
# Every bound entity in this test uses the default MAIN
|
||||
# sub-unit, so the canonical view is just the raw snapshot
|
||||
# subdevice, so the canonical view is just the raw snapshot
|
||||
# (issue #177 -- see LocalThingsEntity._resources).
|
||||
return self.last_resources
|
||||
|
||||
|
||||
@@ -29,9 +29,9 @@ class _FakeCoordinator:
|
||||
def resource(self, href):
|
||||
return self.last_resources.get(href, {})
|
||||
|
||||
def canonical_resources(self, sub_unit):
|
||||
def canonical_resources(self, subdevice):
|
||||
# Every bound entity in this test uses the default MAIN
|
||||
# sub-unit, so the canonical view is just the raw snapshot
|
||||
# subdevice, so the canonical view is just the raw snapshot
|
||||
# (issue #177 -- see LocalThingsEntity._resources).
|
||||
return self.last_resources
|
||||
|
||||
|
||||
@@ -47,9 +47,9 @@ class _FakeCoordinator:
|
||||
def resource(self, href):
|
||||
return self.last_resources.get(href, {})
|
||||
|
||||
def canonical_resources(self, sub_unit):
|
||||
def canonical_resources(self, subdevice):
|
||||
# Every bound entity in this test uses the default MAIN
|
||||
# sub-unit, so the canonical view is just the raw snapshot
|
||||
# subdevice, so the canonical view is just the raw snapshot
|
||||
# (issue #177 -- see LocalThingsEntity._resources).
|
||||
return self.last_resources
|
||||
|
||||
|
||||
@@ -34,9 +34,9 @@ class _FakeCoordinator:
|
||||
def resource(self, href):
|
||||
return self.last_resources.get(href, {})
|
||||
|
||||
def canonical_resources(self, sub_unit):
|
||||
def canonical_resources(self, subdevice):
|
||||
# Every bound entity in this test uses the default MAIN
|
||||
# sub-unit, so the canonical view is just the raw snapshot
|
||||
# subdevice, so the canonical view is just the raw snapshot
|
||||
# (issue #177 -- see LocalThingsEntity._resources).
|
||||
return self.last_resources
|
||||
|
||||
|
||||
@@ -106,8 +106,8 @@ def test_fac_bora_wind_strength_codes_fit_the_standard_scale():
|
||||
def resource(self, href):
|
||||
return self.last_resources.get(href, {})
|
||||
|
||||
def canonical_resources(self, sub_unit):
|
||||
# This test's entity uses the default MAIN sub-unit, so the
|
||||
def canonical_resources(self, subdevice):
|
||||
# This test's entity uses the default MAIN subdevice, so the
|
||||
# canonical view is just the raw snapshot (issue #177).
|
||||
return self.last_resources
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
"""Tests that a sub-unit's LocalThingsClimate entity (issue #177) reads its
|
||||
"""Tests that a subdevice's LocalThingsClimate entity (issue #177) reads its
|
||||
*own* power/mode/temperature -- not the master's, and not some mix of the
|
||||
two -- and that the legacy-board test (is_legacy_board/_legacy_airflow) is
|
||||
evaluated per unit rather than once globally.
|
||||
evaluated per subdevice rather than once globally.
|
||||
|
||||
Uses HJcom's ARTIK051_DONGLE_FAC_18K fixture deliberately: it's a legacy
|
||||
`/airflow/vs/<n>` board (no `/wind/*` at all) on *both* the master and its
|
||||
materialized sibling, which is exactly the shape climate.py's own comments
|
||||
warn is easy to get wrong if the canonical view leaks between units.
|
||||
warn is easy to get wrong if the canonical view leaks between subdevices.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -21,12 +21,12 @@ from tests.test_subdevice_discovery import _coordinator, _discover
|
||||
|
||||
|
||||
def _climate_entities(coordinator):
|
||||
"""{sub_unit_key_or_None: LocalThingsClimate}, None standing for MAIN."""
|
||||
from custom_components.localthings.registry.subunits import MAIN
|
||||
"""{subdevice_key_or_None: LocalThingsClimate}, None standing for MAIN."""
|
||||
from custom_components.localthings.registry.subdevices import MAIN
|
||||
out = {}
|
||||
for b in coordinator.bound:
|
||||
if isinstance(b.desc, ClimateDesc):
|
||||
key = None if b.sub_unit == MAIN else b.sub_unit.key
|
||||
key = None if b.subdevice == MAIN else b.subdevice.key
|
||||
out[key] = LocalThingsClimate(coordinator, b)
|
||||
return out
|
||||
|
||||
@@ -38,66 +38,66 @@ async def climates(hass: HomeAssistant):
|
||||
return _climate_entities(coordinator)
|
||||
|
||||
|
||||
async def test_sub_unit_climate_reads_its_own_mode_and_power(climates):
|
||||
"""Master reports 'Auto'; the bedroom unit (/device/1) reports 'Cool' --
|
||||
confirmed distinct in the real captured fixture. If the sub-unit
|
||||
entity's _rep() weren't translating through its own sub_unit, it would
|
||||
async def test_subdevice_climate_reads_its_own_mode_and_power(climates):
|
||||
"""Master reports 'Auto'; the bedroom subdevice (/device/1) reports 'Cool' --
|
||||
confirmed distinct in the real captured fixture. If the subdevice
|
||||
entity's _rep() weren't translating through its own subdevice, it would
|
||||
read the master's /mode/vs/0 instead and report the master's mode."""
|
||||
main, unit1 = climates[None], climates['1']
|
||||
main, sub1 = climates[None], climates['1']
|
||||
assert main.hvac_mode == HVACMode.AUTO
|
||||
assert unit1.hvac_mode == HVACMode.COOL
|
||||
assert sub1.hvac_mode == HVACMode.COOL
|
||||
|
||||
|
||||
async def test_sub_unit_climate_reads_its_own_temperature(climates):
|
||||
"""Master: current 25.0 / desired 26.0. Unit 1: current 27.0 / desired
|
||||
async def test_subdevice_climate_reads_its_own_temperature(climates):
|
||||
"""Master: current 25.0 / desired 26.0. Subdevice 1: current 27.0 / desired
|
||||
28.0 -- distinct values in the real fixture, so a href mix-up here
|
||||
would show up as a wrong number, not just a wrong mode string."""
|
||||
main, unit1 = climates[None], climates['1']
|
||||
main, sub1 = climates[None], climates['1']
|
||||
assert main.current_temperature == 25.0
|
||||
assert main.target_temperature == 26.0
|
||||
assert unit1.current_temperature == 27.0
|
||||
assert unit1.target_temperature == 28.0
|
||||
assert sub1.current_temperature == 27.0
|
||||
assert sub1.target_temperature == 28.0
|
||||
|
||||
|
||||
async def test_sub_unit_climate_reads_its_own_power_state(climates):
|
||||
"""Both units happen to report power On in this fixture -- this at
|
||||
least confirms _is_on() reads the *unit's own* /power/vs/<n>, not a
|
||||
async def test_subdevice_climate_reads_its_own_power_state(climates):
|
||||
"""Both subdevices happen to report power On in this fixture -- this at
|
||||
least confirms _is_on() reads the *subdevice's own* /power/vs/<n>, not a
|
||||
hardcoded /power/vs/0, by checking the entity resolves without falling
|
||||
back to OFF (which _is_on() would do if it silently read an absent
|
||||
href instead of the sub-unit's actual one)."""
|
||||
main, unit1 = climates[None], climates['1']
|
||||
href instead of the subdevice's actual one)."""
|
||||
main, sub1 = climates[None], climates['1']
|
||||
assert main.hvac_mode != HVACMode.OFF
|
||||
assert unit1.hvac_mode != HVACMode.OFF
|
||||
assert sub1.hvac_mode != HVACMode.OFF
|
||||
|
||||
|
||||
async def test_legacy_board_test_is_evaluated_per_unit(climates):
|
||||
"""HJcom's board has no /wind/* resources at all on *either* unit --
|
||||
async def test_legacy_board_test_is_evaluated_per_subdevice(climates):
|
||||
"""HJcom's board has no /wind/* resources at all on *either* subdevice --
|
||||
is_legacy_board(self._resources) must independently evaluate True for
|
||||
the master's own canonical view and for the sub-unit's own canonical
|
||||
the master's own canonical view and for the subdevice's own canonical
|
||||
view. If is_legacy_board were fed the raw, unpartitioned snapshot (or
|
||||
if canonical_view leaked one unit's hrefs into the other's), this
|
||||
wouldn't distinguish "this unit is legacy" from "some unit on this
|
||||
connection is legacy" -- and a future board with one legacy + one
|
||||
modern unit sharing a connection would silently read the wrong fan/
|
||||
swing channel on one side."""
|
||||
main, unit1 = climates[None], climates['1']
|
||||
if canonical_view leaked one subdevice's hrefs into the other's), this
|
||||
wouldn't distinguish "this subdevice is legacy" from "some subdevice on
|
||||
this connection is legacy" -- and a future board with one legacy + one
|
||||
modern subdevice sharing a connection would silently read the wrong
|
||||
fan/swing channel on one side."""
|
||||
main, sub1 = climates[None], climates['1']
|
||||
assert main._legacy_airflow() != {}
|
||||
assert unit1._legacy_airflow() != {}
|
||||
assert sub1._legacy_airflow() != {}
|
||||
# Both resolve to *some* fan mode via the legacy path rather than the
|
||||
# /wind/strength/vs/0 channel (absent on this board) -- confirms
|
||||
# is_legacy_board(self._resources) actually gated fan_mode's branch,
|
||||
# not just that _legacy_airflow() itself returned something.
|
||||
assert main.fan_mode is not None
|
||||
assert unit1.fan_mode is not None
|
||||
assert sub1.fan_mode is not None
|
||||
|
||||
|
||||
async def test_sub_unit_climate_writes_are_scoped_to_its_own_bound_entity(climates):
|
||||
async def test_subdevice_climate_writes_are_scoped_to_its_own_bound_entity(climates):
|
||||
"""A sanity check that the two entities are backed by genuinely
|
||||
different BoundEntity objects (different hrefs), which is what makes
|
||||
per-unit reads/writes possible at all -- see async_send_command's
|
||||
per-subdevice reads/writes possible at all -- see async_send_command's
|
||||
translation test in test_coordinator_send_command.py for the write
|
||||
side of this."""
|
||||
main, unit1 = climates[None], climates['1']
|
||||
main, sub1 = climates[None], climates['1']
|
||||
assert main._bound.href == '/mode/vs/0'
|
||||
assert unit1._bound.href == '/mode/vs/1'
|
||||
assert main._bound is not unit1._bound
|
||||
assert sub1._bound.href == '/mode/vs/1'
|
||||
assert main._bound is not sub1._bound
|
||||
@@ -28,7 +28,7 @@ from custom_components.localthings.registry.capabilities import laundry
|
||||
from custom_components.localthings.registry.capabilities.airconditioner import _climate_write
|
||||
from custom_components.localthings.registry.discovery import BoundEntity
|
||||
from custom_components.localthings.registry.entities import ClimateDesc
|
||||
from custom_components.localthings.registry.subunits import SubUnit
|
||||
from custom_components.localthings.registry.subdevices import Subdevice
|
||||
|
||||
ENTRY_DATA = {
|
||||
CONF_HOST: '10.0.0.199',
|
||||
@@ -98,26 +98,26 @@ async def test_options_write_optimistic_cache_keeps_sibling_tokens(coordinator)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sub-unit write translation (issue #177): a composite-entity write_fn (here
|
||||
# Subdevice write translation (issue #177): a composite-entity write_fn (here
|
||||
# airconditioner._climate_write) returns *canonical* path_segs
|
||||
# (['power', 'vs', '0']) -- async_send_command must translate that through
|
||||
# bound_entity.sub_unit.to_actual before POSTing, applying the optimistic
|
||||
# value, and starting the settle guard, or a sub-unit's climate card would
|
||||
# bound_entity.subdevice.to_actual before POSTing, applying the optimistic
|
||||
# value, and starting the settle guard, or a subdevice's climate card would
|
||||
# write to (and read confirmation from) the master's resource instead of
|
||||
# its own.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _climate_bound(href: str, sub_unit: SubUnit) -> BoundEntity:
|
||||
def _climate_bound(href: str, subdevice: Subdevice) -> BoundEntity:
|
||||
desc = ClimateDesc(key='climate', translation_key='airconditioner',
|
||||
write_fn=_climate_write)
|
||||
return BoundEntity(href=href, capability=None, desc=desc, sub_unit=sub_unit)
|
||||
return BoundEntity(href=href, capability=None, desc=desc, subdevice=subdevice)
|
||||
|
||||
|
||||
async def test_indexed_sub_unit_write_posts_to_translated_path(coordinator) -> None:
|
||||
"""A power write from the bedroom unit's (indexed '1') climate entity
|
||||
async def test_indexed_subdevice_write_posts_to_translated_path(coordinator) -> None:
|
||||
"""A power write from the bedroom subdevice's (indexed '1') climate entity
|
||||
must POST to /power/vs/1, not the master's /power/vs/0."""
|
||||
unit1 = SubUnit(kind='indexed', key='1', seed_path=('device', '1'))
|
||||
bound = _climate_bound('/mode/vs/1', unit1)
|
||||
sub1 = Subdevice(kind='indexed', key='1', seed_path=('device', '1'))
|
||||
bound = _climate_bound('/mode/vs/1', sub1)
|
||||
|
||||
await coordinator.async_send_command(bound, ('power', True))
|
||||
|
||||
@@ -126,11 +126,11 @@ async def test_indexed_sub_unit_write_posts_to_translated_path(coordinator) -> N
|
||||
assert cbor2.loads(posted_bytes) == {'x.com.samsung.da.power': 'On'}
|
||||
|
||||
|
||||
async def test_indexed_sub_unit_write_applies_optimistic_value_to_translated_href(
|
||||
async def test_indexed_subdevice_write_applies_optimistic_value_to_translated_href(
|
||||
coordinator,
|
||||
) -> None:
|
||||
unit1 = SubUnit(kind='indexed', key='1', seed_path=('device', '1'))
|
||||
bound = _climate_bound('/mode/vs/1', unit1)
|
||||
sub1 = Subdevice(kind='indexed', key='1', seed_path=('device', '1'))
|
||||
bound = _climate_bound('/mode/vs/1', sub1)
|
||||
|
||||
await coordinator.async_send_command(bound, ('power', True))
|
||||
|
||||
@@ -142,7 +142,7 @@ async def test_indexed_sub_unit_write_applies_optimistic_value_to_translated_hre
|
||||
# The settle guard is armed on that same translated href: a stale poll
|
||||
# reporting the pre-write value must be dropped, not allowed to revert
|
||||
# the optimistic 'On' (issue #27's regression, translated to a
|
||||
# sub-unit's own resource).
|
||||
# subdevice's own resource).
|
||||
applied = coordinator._observe.apply(
|
||||
'/power/vs/1', {'x.com.samsung.da.power': 'Off'}, source='poll',
|
||||
)
|
||||
@@ -150,12 +150,12 @@ async def test_indexed_sub_unit_write_applies_optimistic_value_to_translated_hre
|
||||
assert coordinator._cache.get('/power/vs/1') == {'x.com.samsung.da.power': 'On'}
|
||||
|
||||
|
||||
async def test_prefixed_sub_unit_write_posts_to_translated_path(coordinator) -> None:
|
||||
"""A power write from a UUID-prefixed unit's climate entity must POST
|
||||
async def test_prefixed_subdevice_write_posts_to_translated_path(coordinator) -> None:
|
||||
"""A power write from a UUID-prefixed subdevice's climate entity must POST
|
||||
to /<uuid>/power/vs/0, not the bare canonical href."""
|
||||
sub_id = '6c2dff6d-ee5c-dad1-6a5e-000000000001'
|
||||
unit = SubUnit(kind='prefixed', key=sub_id, seed_path=(sub_id, 'device', '0'))
|
||||
bound = _climate_bound(f'/{sub_id}/mode/vs/0', unit)
|
||||
subdevice = Subdevice(kind='prefixed', key=sub_id, seed_path=(sub_id, 'device', '0'))
|
||||
bound = _climate_bound(f'/{sub_id}/mode/vs/0', subdevice)
|
||||
|
||||
await coordinator.async_send_command(bound, ('power', True))
|
||||
|
||||
@@ -167,11 +167,11 @@ async def test_prefixed_sub_unit_write_posts_to_translated_path(coordinator) ->
|
||||
}
|
||||
|
||||
|
||||
async def test_main_climate_write_unaffected_by_sub_unit_translation(coordinator) -> None:
|
||||
async def test_main_climate_write_unaffected_by_subdevice_translation(coordinator) -> None:
|
||||
"""MAIN's to_actual is the identity transform -- a device with no
|
||||
sub-units must keep posting to the exact same path as before this
|
||||
subdevices must keep posting to the exact same path as before this
|
||||
translation step existed."""
|
||||
from custom_components.localthings.registry.subunits import MAIN
|
||||
from custom_components.localthings.registry.subdevices import MAIN
|
||||
bound = _climate_bound('/mode/vs/0', MAIN)
|
||||
|
||||
await coordinator.async_send_command(bound, ('power', True))
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""Sanity check for diagnostics.py's issue #177 additions (sub_units,
|
||||
sub_units_skipped, sub_unit_probes) against a real composite-device
|
||||
"""Sanity check for diagnostics.py's issue #177 additions (subdevices,
|
||||
subdevices_skipped, subdevice_probes) against a real composite-device
|
||||
discovery run -- makes sure the new blocks are actually reachable/shaped
|
||||
right, not just that the coordinator's own attributes look correct in
|
||||
isolation (test_subdevice_discovery.py covers that)."""
|
||||
@@ -15,7 +15,7 @@ from custom_components.localthings.diagnostics import (
|
||||
from tests.test_subdevice_discovery import _coordinator, _discover
|
||||
|
||||
|
||||
async def test_diagnostics_reports_materialized_and_skipped_sub_units(
|
||||
async def test_diagnostics_reports_materialized_and_skipped_subdevices(
|
||||
hass: HomeAssistant, enable_custom_integrations,
|
||||
) -> None:
|
||||
coordinator = _coordinator(hass)
|
||||
@@ -24,27 +24,27 @@ async def test_diagnostics_reports_materialized_and_skipped_sub_units(
|
||||
|
||||
diag = await async_get_config_entry_diagnostics(hass, coordinator._entry)
|
||||
|
||||
sub_unit_keys = {su['key'] for su in diag['sub_units']}
|
||||
skipped_keys = {su['key'] for su in diag['sub_units_skipped']}
|
||||
assert sub_unit_keys == {'1'}
|
||||
subdevice_keys = {su['key'] for su in diag['subdevices']}
|
||||
skipped_keys = {su['key'] for su in diag['subdevices_skipped']}
|
||||
assert subdevice_keys == {'1'}
|
||||
assert skipped_keys == {'2'}
|
||||
assert diag['sub_units'][0]['bound_entity_count'] > 0
|
||||
assert diag['sub_units_skipped'][0]['hrefs']
|
||||
assert '/multidevice/vs/0' in diag['sub_unit_probes']
|
||||
assert diag['subdevices'][0]['bound_entity_count'] > 0
|
||||
assert diag['subdevices_skipped'][0]['hrefs']
|
||||
assert '/multidevice/vs/0' in diag['subdevice_probes']
|
||||
# The reporter's hand-read value, carried in the fixture's `probes` map
|
||||
# (it belongs to no batch -- see that fixture's seeds_note). Two real
|
||||
# units, master + bedroom, independently corroborating the gate's
|
||||
# subdevices, master + bedroom, independently corroborating the gate's
|
||||
# decision to skip /device/2. Reported on its own, never as a resource.
|
||||
assert diag['multidevice'] == {'x.com.samsung.da.numofsubdevice': '2'}
|
||||
assert '/multidevice/vs/0' not in diag['resources']
|
||||
|
||||
|
||||
async def test_top_level_resources_is_the_master_unit_only(
|
||||
async def test_top_level_resources_is_the_master_subdevice_only(
|
||||
hass: HomeAssistant, enable_custom_integrations,
|
||||
) -> None:
|
||||
"""`resources` reports this unit's own hrefs and nothing else.
|
||||
"""`resources` reports this subdevice's own hrefs and nothing else.
|
||||
|
||||
It used to be `last_resources` raw -- the union across every live unit,
|
||||
It used to be `last_resources` raw -- the union across every live subdevice,
|
||||
keyed by real hrefs -- so a sibling's /mode/vs/1 sat in it alongside the
|
||||
master's /mode/vs/0 with no attribution, contradicting both the module
|
||||
docstring and the adding-device-support skill ("the parsed /device/0
|
||||
@@ -60,10 +60,10 @@ async def test_top_level_resources_is_the_master_unit_only(
|
||||
assert '/mode/vs/0' in diag['resources']
|
||||
# The sibling's own block carries its state, canonicalized -- '/mode/vs/0',
|
||||
# not the '/mode/vs/1' it actually answers on.
|
||||
unit1 = diag['sub_units'][0]
|
||||
assert '/mode/vs/0' in unit1['resources']
|
||||
assert not [h for h in unit1['resources'] if h.endswith('/1')]
|
||||
assert unit1['resources']['/power/vs/0']['x.com.samsung.da.power'] == 'On'
|
||||
sub1 = diag["subdevices"][0]
|
||||
assert '/mode/vs/0' in sub1["resources"]
|
||||
assert not [h for h in sub1['resources'] if h.endswith('/1')]
|
||||
assert sub1["resources"]["/power/vs/0"]['x.com.samsung.da.power'] == 'On'
|
||||
|
||||
|
||||
async def test_rejected_candidate_reps_reach_diagnostics_but_not_the_cache(
|
||||
@@ -72,7 +72,7 @@ async def test_rejected_candidate_reps_reach_diagnostics_but_not_the_cache(
|
||||
"""A gate-rejected slot is never polled again, so anything applied to the
|
||||
state cache for it would sit frozen at its first-discovery value while
|
||||
looking as live as every other href. It's kept out of the cache entirely
|
||||
and reported only under its own sub_units_skipped entry -- which is also
|
||||
and reported only under its own subdevices_skipped entry -- which is also
|
||||
the only place a reader can go to second-guess the gate."""
|
||||
coordinator = _coordinator(hass)
|
||||
await _discover(coordinator, 'airconditioner_artik051_dongle_fac_18k')
|
||||
@@ -81,14 +81,14 @@ async def test_rejected_candidate_reps_reach_diagnostics_but_not_the_cache(
|
||||
assert not [h for h in coordinator.last_resources if h.endswith('/2')]
|
||||
|
||||
diag = await async_get_config_entry_diagnostics(hass, coordinator._entry)
|
||||
skipped = diag['sub_units_skipped'][0]
|
||||
skipped = diag['subdevices_skipped'][0]
|
||||
# Present, canonicalized, and visibly the empty state the gate rejected.
|
||||
assert skipped['resources']['/power/vs/0'] == {}
|
||||
assert skipped['resources']['/mode/vs/0'] == {}
|
||||
assert skipped['resources']['/information/vs/0']
|
||||
|
||||
|
||||
async def test_diagnostics_reports_prefixed_sub_unit(
|
||||
async def test_diagnostics_reports_prefixed_subdevice(
|
||||
hass: HomeAssistant, enable_custom_integrations,
|
||||
) -> None:
|
||||
coordinator = _coordinator(hass)
|
||||
@@ -97,11 +97,11 @@ async def test_diagnostics_reports_prefixed_sub_unit(
|
||||
|
||||
diag = await async_get_config_entry_diagnostics(hass, coordinator._entry)
|
||||
|
||||
assert len(diag['sub_units']) == 1
|
||||
assert diag['sub_units'][0]['kind'] == 'prefixed'
|
||||
assert len(diag['subdevices']) == 1
|
||||
assert diag['subdevices'][0]['kind'] == 'prefixed'
|
||||
# diagnostics reports the raw modelNum field verbatim (board revision/
|
||||
# capability-bitmap suffix included), unlike device_info_for's model
|
||||
# (split at '|') -- confirms it's still the wall unit's own identity,
|
||||
# (split at '|') -- confirms it's still the wall subdevice's own identity,
|
||||
# not the master's TP2X_FAC_BORA_21K.
|
||||
assert diag['sub_units'][0]['model'].startswith('TP2X_FAC_BORA_RAC_21K')
|
||||
assert diag['sub_units_skipped'] == []
|
||||
assert diag['subdevices'][0]['model'].startswith('TP2X_FAC_BORA_RAC_21K')
|
||||
assert diag['subdevices_skipped'] == []
|
||||
@@ -17,12 +17,12 @@ class _FakeCoordinator:
|
||||
def __init__(self, last_resources):
|
||||
self.last_resources = last_resources
|
||||
|
||||
def canonical_resources(self, sub_unit):
|
||||
def canonical_resources(self, subdevice):
|
||||
# Every bound entity in this test file uses the default MAIN
|
||||
# sub-unit (identity transform), so the canonical view is just the
|
||||
# subdevice (identity transform), so the canonical view is just the
|
||||
# raw snapshot -- same shape as the real
|
||||
# LocalThingsCoordinator.canonical_resources for a device with no
|
||||
# sub-units (issue #177).
|
||||
# subdevices (issue #177).
|
||||
return self.last_resources
|
||||
|
||||
|
||||
|
||||
@@ -859,12 +859,12 @@ def test_registry_reproduces_golden_state_keys_for_airconditioner_fac_bora():
|
||||
)
|
||||
|
||||
|
||||
def _new_sub_unit_aware_state_keys(name):
|
||||
"""Like _new_state_keys, but runs the full sub-unit-aware pipeline
|
||||
(enumerate_sub_units + discover_partitioned, issue #177) instead of a
|
||||
def _new_subdevice_aware_state_keys(name):
|
||||
"""Like _new_state_keys, but runs the full subdevice-aware pipeline
|
||||
(enumerate_subdevices + discover_partitioned, issue #177) instead of a
|
||||
single discover() call, so the golden for a composite-device fixture
|
||||
captures every materialized unit's keys (unit1_-/sub_<uuid>_-prefixed),
|
||||
not just the master's."""
|
||||
captures every materialized subdevice's keys
|
||||
(subdevice1_-/subdevice_<uuid>_-prefixed), not just the master's."""
|
||||
from custom_components.localthings.registry.adapter import flatten
|
||||
from tests.conftest import _discover_full, _load_device_full
|
||||
resources, oic_res, seeds = _load_device_full(name)
|
||||
@@ -877,15 +877,16 @@ def _new_sub_unit_aware_state_keys(name):
|
||||
|
||||
def test_registry_reproduces_golden_state_keys_for_airconditioner_artik051_dongle_fac_18k():
|
||||
"""HJcom's ARTIK051_DONGLE_FAC_18K (issue #177, Pattern A -- indexed
|
||||
siblings): a real v0.16.0 dump with a genuine second indoor unit at
|
||||
`/device/1` (unit1_-prefixed keys below) and an unused SmartThings slot
|
||||
at `/device/2` that answers its seed but never produces a materialized
|
||||
unit (see DESIGN-177.md section 4 and test_subdevice_discovery.py's
|
||||
explicit "/device/2 produces no entities" assertion) -- so this golden
|
||||
has no `unit2_`-prefixed keys at all, which is the point."""
|
||||
siblings): a real v0.16.0 dump with a genuine second indoor subdevice at
|
||||
`/device/1` (subdevice1_-prefixed keys below) and an unused SmartThings
|
||||
slot at `/device/2` that answers its seed but never produces a
|
||||
materialized subdevice (see DESIGN-177.md section 4 and
|
||||
test_subdevice_discovery.py's explicit "/device/2 produces no entities"
|
||||
assertion) -- so this golden has no `subdevice2_`-prefixed keys at all,
|
||||
which is the point."""
|
||||
name = 'airconditioner_artik051_dongle_fac_18k'
|
||||
golden = json.loads((GOLDEN / f'{name}.json').read_text())
|
||||
state_keys = _new_sub_unit_aware_state_keys(name)
|
||||
state_keys = _new_subdevice_aware_state_keys(name)
|
||||
assert set(state_keys) == set(golden['state_keys']), (
|
||||
f"state_keys mismatch:\n"
|
||||
f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n"
|
||||
@@ -915,17 +916,17 @@ def test_registry_reproduces_golden_state_keys_for_airconditioner_cac():
|
||||
|
||||
def test_registry_reproduces_golden_state_keys_for_airconditioner_fac_bora_2in1():
|
||||
"""jhkwon19's TP2X_FAC_BORA_21K (issue #177, Pattern B -- UUID-prefixed
|
||||
tree): device0/oic_res are real; the wall-mounted sub-unit's own
|
||||
tree): device0/oic_res are real; the wall-mounted subdevice's own
|
||||
/information/vs/0 is real (confirmed live by the reporter), the rest of
|
||||
its seed tree is constructed (see the fixture's own seeds_note) -- just
|
||||
enough to bind a real climate card under the
|
||||
`sub_6c2dff6dee5cdad16a5e000000000001_` prefix below. Distinct from
|
||||
`subdevice_6c2dff6dee5cdad16a5e000000000001_` prefix below. Distinct from
|
||||
tests/fixtures/airconditioner_fac_bora_device.json, which is
|
||||
deliberately left unchanged as the redacted-subdeviceIdList regression
|
||||
case (zero sub-units)."""
|
||||
case (zero subdevices)."""
|
||||
name = 'airconditioner_fac_bora_2in1'
|
||||
golden = json.loads((GOLDEN / f'{name}.json').read_text())
|
||||
state_keys = _new_sub_unit_aware_state_keys(name)
|
||||
state_keys = _new_subdevice_aware_state_keys(name)
|
||||
assert set(state_keys) == set(golden['state_keys']), (
|
||||
f"state_keys mismatch:\n"
|
||||
f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n"
|
||||
|
||||
@@ -19,9 +19,9 @@ class _FakeCoordinator:
|
||||
def resource(self, href):
|
||||
return self.last_resources.get(href, {})
|
||||
|
||||
def canonical_resources(self, sub_unit):
|
||||
def canonical_resources(self, subdevice):
|
||||
# Every bound entity in this test uses the default MAIN
|
||||
# sub-unit, so the canonical view is just the raw snapshot
|
||||
# subdevice, so the canonical view is just the raw snapshot
|
||||
# (issue #177 -- see LocalThingsEntity._resources).
|
||||
return self.last_resources
|
||||
|
||||
|
||||
@@ -14,9 +14,9 @@ class _FakeCoordinator:
|
||||
def __init__(self, last_resources):
|
||||
self.last_resources = last_resources
|
||||
|
||||
def canonical_resources(self, sub_unit):
|
||||
def canonical_resources(self, subdevice):
|
||||
# Every entity built by _make_select uses the default MAIN
|
||||
# sub-unit, so the canonical view is just the raw snapshot
|
||||
# subdevice, so the canonical view is just the raw snapshot
|
||||
# (issue #177 -- see LocalThingsEntity._resources).
|
||||
return self.last_resources
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""End-to-end discovery tests for issue #177's two composite-device
|
||||
fixtures, against the real LocalThingsCoordinator (not the HA-free
|
||||
registry-level helpers test_subunits.py/test_unique_ids.py use) -- this is
|
||||
what actually exercises _enumerate_sub_units_blocking + _run_discovery
|
||||
registry-level helpers test_subdevices.py/test_unique_ids.py use) -- this is
|
||||
what actually exercises _enumerate_subdevices_blocking + _run_discovery
|
||||
together, including device_info_for/via_device and the "no phantom
|
||||
/device/2 entities" guarantee.
|
||||
"""
|
||||
@@ -40,7 +40,7 @@ async def _discover(coordinator: LocalThingsCoordinator, name: str) -> None:
|
||||
"""Run the same two-step sequence _async_update_data's first cycle does
|
||||
(enumerate, then discover) against fixture data, without the polling/
|
||||
reconnect machinery around it -- see coordinator.py's
|
||||
_enumerate_sub_units_blocking/_run_discovery."""
|
||||
_enumerate_subdevices_blocking/_run_discovery."""
|
||||
resources, oic_res, seeds = _load_device_full(name)
|
||||
coordinator._session = FakeCoapSession(seeds)
|
||||
# _connect_session (skipped here -- the session is pre-set) is what
|
||||
@@ -52,7 +52,7 @@ async def _discover(coordinator: LocalThingsCoordinator, name: str) -> None:
|
||||
device_types=(), raw={'/oic/p': {}, '/oic/d': {}, '/oic/res': oic_res},
|
||||
)
|
||||
merged = await coordinator.hass.async_add_executor_job(
|
||||
coordinator._enumerate_sub_units_blocking, resources,
|
||||
coordinator._enumerate_subdevices_blocking, resources,
|
||||
)
|
||||
# Mirror _async_update_data's first-cycle order exactly: discover, then
|
||||
# drop the candidates the liveness gate rejected, then apply what's left
|
||||
@@ -63,17 +63,17 @@ async def _discover(coordinator: LocalThingsCoordinator, name: str) -> None:
|
||||
# first here would leave this helper testing an ordering production no
|
||||
# longer uses.
|
||||
coordinator._run_discovery(merged)
|
||||
for href, rep in coordinator._live_unit_resources(merged).items():
|
||||
for href, rep in coordinator._live_subdevice_resources(merged).items():
|
||||
coordinator._observe.apply(href, rep, source='poll')
|
||||
|
||||
|
||||
def _climate_bound(coordinator, sub_unit_key: str):
|
||||
from custom_components.localthings.registry.subunits import MAIN
|
||||
def _climate_bound(coordinator, subdevice_key: str):
|
||||
from custom_components.localthings.registry.subdevices import MAIN
|
||||
for b in coordinator.bound:
|
||||
if isinstance(b.desc, ClimateDesc):
|
||||
if sub_unit_key is None and b.sub_unit == MAIN:
|
||||
if subdevice_key is None and b.subdevice == MAIN:
|
||||
return b
|
||||
if sub_unit_key is not None and b.sub_unit.key == sub_unit_key:
|
||||
if subdevice_key is not None and b.subdevice.key == subdevice_key:
|
||||
return b
|
||||
return None
|
||||
|
||||
@@ -82,18 +82,18 @@ def _climate_bound(coordinator, sub_unit_key: str):
|
||||
# HJcom -- ARTIK051_DONGLE_FAC_18K, Pattern A (indexed siblings)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def test_hjcom_materializes_master_and_bedroom_unit(hass: HomeAssistant):
|
||||
async def test_hjcom_materializes_master_and_bedroom_subdevice(hass: HomeAssistant):
|
||||
coordinator = _coordinator(hass)
|
||||
await _discover(coordinator, 'airconditioner_artik051_dongle_fac_18k')
|
||||
|
||||
assert [su.key for su in coordinator.sub_units] == ['1']
|
||||
assert [su.key for su in coordinator.subdevices] == ['1']
|
||||
|
||||
main_climate = _climate_bound(coordinator, None)
|
||||
unit1_climate = _climate_bound(coordinator, '1')
|
||||
sub1_climate = _climate_bound(coordinator, '1')
|
||||
assert main_climate is not None
|
||||
assert unit1_climate is not None
|
||||
assert sub1_climate is not None
|
||||
assert main_climate.href == '/mode/vs/0'
|
||||
assert unit1_climate.href == '/mode/vs/1'
|
||||
assert sub1_climate.href == '/mode/vs/1'
|
||||
|
||||
|
||||
async def test_hjcom_device_2_produces_no_entities_at_all(hass: HomeAssistant):
|
||||
@@ -104,27 +104,27 @@ async def test_hjcom_device_2_produces_no_entities_at_all(hass: HomeAssistant):
|
||||
coordinator = _coordinator(hass)
|
||||
await _discover(coordinator, 'airconditioner_artik051_dongle_fac_18k')
|
||||
|
||||
assert '2' not in [su.key for su in coordinator.sub_units]
|
||||
assert '2' not in [su.key for su in coordinator.subdevices]
|
||||
assert any(
|
||||
skip.sub_unit.kind == 'indexed' and skip.sub_unit.key == '2'
|
||||
for skip in coordinator._skipped_sub_units
|
||||
skip.subdevice.kind == 'indexed' and skip.subdevice.key == '2'
|
||||
for skip in coordinator._skipped_subdevices
|
||||
)
|
||||
assert not any(b.sub_unit.key == '2' for b in coordinator.bound)
|
||||
assert not any(b.subdevice.key == '2' for b in coordinator.bound)
|
||||
assert not any(href.endswith('/2') for href in coordinator._hot_hrefs)
|
||||
assert not any(href.endswith('/2') for href in coordinator._warm_hrefs)
|
||||
|
||||
|
||||
async def test_hjcom_unit1_device_info_links_via_device_to_master(hass: HomeAssistant):
|
||||
async def test_hjcom_sub1_device_info_links_via_device_to_master(hass: HomeAssistant):
|
||||
coordinator = _coordinator(hass)
|
||||
await _discover(coordinator, 'airconditioner_artik051_dongle_fac_18k')
|
||||
|
||||
unit1 = next(su for su in coordinator.sub_units if su.key == '1')
|
||||
info = coordinator.device_info_for(unit1)
|
||||
sub1 = next(su for su in coordinator.subdevices if su.key == '1')
|
||||
info = coordinator.device_info_for(sub1)
|
||||
|
||||
master_serial = coordinator.device_serial
|
||||
assert info['identifiers'] == {(DOMAIN, f'{master_serial}_1')}
|
||||
assert info['via_device'] == (DOMAIN, master_serial)
|
||||
# The sub-unit's own /information/vs/1 (real, ARTIK051_DONGLE_FAC_RAC_18K)
|
||||
# The subdevice's own /information/vs/1 (real, ARTIK051_DONGLE_FAC_RAC_18K)
|
||||
# is what names/models this device, not the master's.
|
||||
assert info['model'] == 'ARTIK051_DONGLE_FAC_RAC_18K'
|
||||
|
||||
@@ -136,12 +136,12 @@ async def test_hjcom_unit1_device_info_links_via_device_to_master(hass: HomeAssi
|
||||
_SUB_UUID = '6c2dff6d-ee5c-dad1-6a5e-000000000001'
|
||||
|
||||
|
||||
async def test_fac_bora_2in1_materializes_prefixed_wall_unit(hass: HomeAssistant):
|
||||
async def test_fac_bora_2in1_materializes_prefixed_wall_subdevice(hass: HomeAssistant):
|
||||
coordinator = _coordinator(hass)
|
||||
await _discover(coordinator, 'airconditioner_fac_bora_2in1')
|
||||
|
||||
assert [su.key for su in coordinator.sub_units] == [_SUB_UUID]
|
||||
assert coordinator.sub_units[0].kind == 'prefixed'
|
||||
assert [su.key for su in coordinator.subdevices] == [_SUB_UUID]
|
||||
assert coordinator.subdevices[0].kind == 'prefixed'
|
||||
|
||||
main_climate = _climate_bound(coordinator, None)
|
||||
sub_climate = _climate_bound(coordinator, _SUB_UUID)
|
||||
@@ -151,25 +151,25 @@ async def test_fac_bora_2in1_materializes_prefixed_wall_unit(hass: HomeAssistant
|
||||
assert sub_climate.href == f'/{_SUB_UUID}/mode/vs/0'
|
||||
|
||||
|
||||
async def test_fac_bora_2in1_sub_unit_device_info(hass: HomeAssistant):
|
||||
async def test_fac_bora_2in1_subdevice_device_info(hass: HomeAssistant):
|
||||
coordinator = _coordinator(hass)
|
||||
await _discover(coordinator, 'airconditioner_fac_bora_2in1')
|
||||
|
||||
unit = coordinator.sub_units[0]
|
||||
info = coordinator.device_info_for(unit)
|
||||
subdevice = coordinator.subdevices[0]
|
||||
info = coordinator.device_info_for(subdevice)
|
||||
|
||||
master_serial = coordinator.device_serial
|
||||
assert info['identifiers'] == {(DOMAIN, f'{master_serial}_{_SUB_UUID}')}
|
||||
assert info['via_device'] == (DOMAIN, master_serial)
|
||||
# Confirmed live by the reporter (DESIGN-177.md section 1): the wall
|
||||
# unit's own identity, distinct from the master's TP2X_FAC_BORA_21K.
|
||||
# subdevice's own identity, distinct from the master's TP2X_FAC_BORA_21K.
|
||||
assert info['model'] == 'TP2X_FAC_BORA_RAC_21K'
|
||||
|
||||
|
||||
async def test_fac_bora_2in1_unique_ids_include_sub_prefix(hass: HomeAssistant):
|
||||
"""The prefixed unit's unique_id carries the full subdevice UUID
|
||||
async def test_fac_bora_2in1_unique_ids_include_subdevice_prefix(hass: HomeAssistant):
|
||||
"""The prefixed subdevice's unique_id carries the full subdevice UUID
|
||||
(non-alphanumerics stripped), not a truncation or an ordinal -- see
|
||||
SubUnit.key_prefix."""
|
||||
Subdevice.key_prefix."""
|
||||
coordinator = _coordinator(hass)
|
||||
await _discover(coordinator, 'airconditioner_fac_bora_2in1')
|
||||
|
||||
@@ -178,7 +178,7 @@ async def test_fac_bora_2in1_unique_ids_include_sub_prefix(hass: HomeAssistant):
|
||||
entity = LocalThingsEntity(coordinator, sub_climate)
|
||||
expected_slug = _SUB_UUID.replace('-', '')
|
||||
assert entity._attr_unique_id == (
|
||||
f"{DOMAIN}_{coordinator.device_serial}_sub_{expected_slug}_climate"
|
||||
f"{DOMAIN}_{coordinator.device_serial}_subdevice_{expected_slug}_climate"
|
||||
)
|
||||
|
||||
|
||||
@@ -207,10 +207,10 @@ async def test_multidevice_probe_never_reaches_discovery_or_the_cache(
|
||||
device_types=(), raw={'/oic/p': {}, '/oic/d': {}, '/oic/res': []},
|
||||
)
|
||||
merged = await hass.async_add_executor_job(
|
||||
coordinator._enumerate_sub_units_blocking, resources,
|
||||
coordinator._enumerate_subdevices_blocking, resources,
|
||||
)
|
||||
coordinator._run_discovery(merged)
|
||||
for href, rep in coordinator._live_unit_resources(merged).items():
|
||||
for href, rep in coordinator._live_subdevice_resources(merged).items():
|
||||
coordinator._observe.apply(href, rep, source='poll')
|
||||
|
||||
assert '/multidevice/vs/0' not in merged
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""Tests for registry/subunits.py -- multi-indoor-unit ("composite device")
|
||||
support, issue #177. See DESIGN-177.md for the two board patterns
|
||||
"""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.
|
||||
"""
|
||||
@@ -9,24 +9,24 @@ import cbor2
|
||||
|
||||
from custom_components.localthings.registry.capability import Capability
|
||||
from custom_components.localthings.registry.entities import BinarySensorDesc, SensorDesc
|
||||
from custom_components.localthings.registry.subunits import (
|
||||
MAIN, SubUnit, canonical_view, discover_partitioned, enumerate_sub_units,
|
||||
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) -> SubUnit:
|
||||
return SubUnit(kind='indexed', key=n, seed_path=('device', n))
|
||||
def _indexed(n: str) -> Subdevice:
|
||||
return Subdevice(kind='indexed', key=n, seed_path=('device', n))
|
||||
|
||||
|
||||
def _prefixed(sub_id: str) -> SubUnit:
|
||||
return SubUnit(kind='prefixed', key=sub_id, seed_path=(sub_id, 'device', '0'))
|
||||
def _prefixed(sub_id: str) -> Subdevice:
|
||||
return Subdevice(kind='prefixed', key=sub_id, seed_path=(sub_id, 'device', '0'))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SubUnit.to_actual / to_canonical / owns / key_prefix
|
||||
# Subdevice.to_actual / to_canonical / owns / key_prefix
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMainIsIdentity:
|
||||
@@ -38,79 +38,79 @@ class TestMainIsIdentity:
|
||||
|
||||
def test_owns_is_always_false(self):
|
||||
"""MAIN never "owns" a href by this definition -- it gets whatever's
|
||||
left after every other sub-unit's hrefs are excluded (canonical_view)."""
|
||||
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 unit's flattened state keys must stay byte-identical
|
||||
"""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):
|
||||
unit = _indexed('1')
|
||||
assert unit.to_actual('/mode/vs/0') == '/mode/vs/1'
|
||||
assert unit.to_actual('/device/0') == '/device/1'
|
||||
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 sub-unit's own href."""
|
||||
unit = _indexed('1')
|
||||
assert unit.to_actual('/door/vs/1') == '/door/vs/1'
|
||||
'/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):
|
||||
unit = _indexed('1')
|
||||
assert unit.to_canonical(unit.to_actual('/mode/vs/0')) == '/mode/vs/0'
|
||||
subdevice = _indexed('1')
|
||||
assert subdevice.to_canonical(subdevice.to_actual('/mode/vs/0')) == '/mode/vs/0'
|
||||
|
||||
def test_to_canonical_rejects_wrong_index(self):
|
||||
unit = _indexed('1')
|
||||
assert unit.to_canonical('/mode/vs/2') is None
|
||||
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 sub-unit."""
|
||||
unit = _indexed('1')
|
||||
assert unit.to_canonical('/mode/vs/0') is None
|
||||
"""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):
|
||||
unit = _indexed('1')
|
||||
assert unit.owns('/mode/vs/1') is True
|
||||
assert unit.owns('/mode/vs/0') is False
|
||||
assert unit.owns('/mode/vs/2') is False
|
||||
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 == 'unit1_'
|
||||
assert _indexed('2').key_prefix == 'unit2_'
|
||||
assert _indexed('1').key_prefix == 'subdevice1_'
|
||||
assert _indexed('2').key_prefix == 'subdevice2_'
|
||||
|
||||
|
||||
class TestPrefixedTransform:
|
||||
def test_to_actual_prepends_id(self):
|
||||
unit = _prefixed(_UUID)
|
||||
assert unit.to_actual('/mode/vs/0') == f'/{_UUID}/mode/vs/0'
|
||||
subdevice = _prefixed(_UUID)
|
||||
assert subdevice.to_actual('/mode/vs/0') == f'/{_UUID}/mode/vs/0'
|
||||
|
||||
def test_to_canonical_round_trips(self):
|
||||
unit = _prefixed(_UUID)
|
||||
assert unit.to_canonical(unit.to_actual('/mode/vs/0')) == '/mode/vs/0'
|
||||
subdevice = _prefixed(_UUID)
|
||||
assert subdevice.to_canonical(subdevice.to_actual('/mode/vs/0')) == '/mode/vs/0'
|
||||
|
||||
def test_to_canonical_rejects_missing_prefix(self):
|
||||
unit = _prefixed(_UUID)
|
||||
assert unit.to_canonical('/mode/vs/0') is None
|
||||
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."""
|
||||
unit = _prefixed(_UUID)
|
||||
assert unit.to_canonical(f'/{_UUID}extra/mode/vs/0') is None
|
||||
subdevice = _prefixed(_UUID)
|
||||
assert subdevice.to_canonical(f'/{_UUID}extra/mode/vs/0') is None
|
||||
|
||||
def test_owns(self):
|
||||
unit = _prefixed(_UUID)
|
||||
assert unit.owns(f'/{_UUID}/mode/vs/0') is True
|
||||
assert unit.owns('/mode/vs/0') is False
|
||||
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 == 'sub_6c2dff6dee5cdad16a5e000000000001_'
|
||||
assert _prefixed(_UUID).key_prefix == 'subdevice_6c2dff6dee5cdad16a5e000000000001_'
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -118,21 +118,21 @@ class TestPrefixedTransform:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCanonicalView:
|
||||
def test_main_with_no_sub_units_is_unchanged(self):
|
||||
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_sub_unit_owned_hrefs(self):
|
||||
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 units' state mixed
|
||||
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'},
|
||||
}
|
||||
unit1 = _indexed('1')
|
||||
view = canonical_view(MAIN, resources, [unit1])
|
||||
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):
|
||||
@@ -141,8 +141,8 @@ class TestCanonicalView:
|
||||
'/mode/vs/1': {'unit': 'sibling'},
|
||||
'/power/vs/1': {'p': 'On'},
|
||||
}
|
||||
unit1 = _indexed('1')
|
||||
view = canonical_view(unit1, resources, [unit1])
|
||||
sub1 = _indexed('1')
|
||||
view = canonical_view(sub1, resources, [sub1])
|
||||
assert view == {
|
||||
'/mode/vs/0': {'unit': 'sibling'},
|
||||
'/power/vs/0': {'p': 'On'},
|
||||
@@ -153,17 +153,17 @@ class TestCanonicalView:
|
||||
f'/{_UUID}/mode/vs/0': {'unit': 'sub'},
|
||||
'/mode/vs/0': {'unit': 'main'},
|
||||
}
|
||||
unit = _prefixed(_UUID)
|
||||
view = canonical_view(unit, resources, [unit])
|
||||
subdevice = _prefixed(_UUID)
|
||||
view = canonical_view(subdevice, resources, [subdevice])
|
||||
assert view == {'/mode/vs/0': {'unit': 'sub'}}
|
||||
|
||||
def test_including_main_in_sub_units_is_harmless(self):
|
||||
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'}}
|
||||
unit1 = _indexed('1')
|
||||
assert (canonical_view(MAIN, resources, [MAIN, unit1])
|
||||
== canonical_view(MAIN, resources, [unit1]))
|
||||
sub1 = _indexed('1')
|
||||
assert (canonical_view(MAIN, resources, [MAIN, sub1])
|
||||
== canonical_view(MAIN, resources, [sub1]))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -171,25 +171,25 @@ class TestCanonicalView:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_normalize_seed_batch_indexed_is_a_no_op():
|
||||
unit = _indexed('1')
|
||||
subdevice = _indexed('1')
|
||||
batch = {'/mode/vs/1': {'a': 1}}
|
||||
assert normalize_seed_batch(unit, batch) == batch
|
||||
assert normalize_seed_batch(subdevice, batch) == batch
|
||||
|
||||
|
||||
def test_normalize_seed_batch_prefixed_adds_missing_prefix():
|
||||
unit = _prefixed(_UUID)
|
||||
subdevice = _prefixed(_UUID)
|
||||
batch = {'/mode/vs/0': {'a': 1}}
|
||||
assert normalize_seed_batch(unit, batch) == {f'/{_UUID}/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():
|
||||
unit = _prefixed(_UUID)
|
||||
subdevice = _prefixed(_UUID)
|
||||
batch = {f'/{_UUID}/mode/vs/0': {'a': 1}}
|
||||
assert normalize_seed_batch(unit, batch) == batch
|
||||
assert normalize_seed_batch(subdevice, batch) == batch
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# enumerate_sub_units
|
||||
# enumerate_subdevices
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class _FakeSession:
|
||||
@@ -218,8 +218,8 @@ def test_enumerate_indexed_from_oic_res_links():
|
||||
('device', '1'): [_DEVCOL_REP, {'href': '/mode/vs/1', 'rep': {'m': 1}}],
|
||||
('device', '2'): [_DEVCOL_REP, {'href': '/mode/vs/2', 'rep': {'m': 2}}],
|
||||
})
|
||||
units, extra = enumerate_sub_units(sess, {}, oic_res)
|
||||
assert sorted((u.kind, u.key) for u in units) == [
|
||||
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}}
|
||||
@@ -234,18 +234,18 @@ def test_enumerate_indexed_falls_back_to_speculative_probe_when_oic_res_hides_th
|
||||
('device', '1'): [_DEVCOL_REP, {'href': '/mode/vs/1', 'rep': {'m': 1}}],
|
||||
# /device/2 not in the table -> 4.04 -> not materialized.
|
||||
})
|
||||
units, extra = enumerate_sub_units(sess, {}, oic_res_links=[])
|
||||
assert [(u.kind, u.key) for u in units] == [('indexed', '1')]
|
||||
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_units_with_a_non_empty_batch():
|
||||
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 unit real" gate."""
|
||||
"is this subdevice real" gate."""
|
||||
sess = _FakeSession({}) # neither /device/1 nor /device/2 answers
|
||||
units, extra = enumerate_sub_units(sess, {}, oic_res_links=[])
|
||||
assert units == []
|
||||
subdevices, extra = enumerate_subdevices(sess, {}, oic_res_links=[])
|
||||
assert subdevices == []
|
||||
assert extra == {}
|
||||
|
||||
|
||||
@@ -258,8 +258,8 @@ def test_enumerate_prefixed_from_subdevice_id_list():
|
||||
_DEVCOL_REP, {'href': '/mode/vs/0', 'rep': {'m': 'cool'}},
|
||||
],
|
||||
})
|
||||
units, extra = enumerate_sub_units(sess, resources, oic_res_links=[])
|
||||
assert [(u.kind, u.key) for u in units] == [('prefixed', _UUID)]
|
||||
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'}}
|
||||
|
||||
@@ -267,23 +267,23 @@ def test_enumerate_prefixed_from_subdevice_id_list():
|
||||
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 sub-units, not crash."""
|
||||
redacted string there -- this must yield zero subdevices, not crash."""
|
||||
resources = {
|
||||
'/subdevices/vs/0': {'x.com.samsung.da.subdeviceIdList': 'REDACTED'},
|
||||
}
|
||||
units, extra = enumerate_sub_units(_FakeSession({}), resources, oic_res_links=[])
|
||||
assert units == []
|
||||
subdevices, extra = enumerate_subdevices(_FakeSession({}), resources, oic_res_links=[])
|
||||
assert subdevices == []
|
||||
assert extra == {}
|
||||
|
||||
|
||||
def test_enumerate_no_subdevices_resource_at_all():
|
||||
units, extra = enumerate_sub_units(_FakeSession({}), {}, oic_res_links=[])
|
||||
assert units == []
|
||||
subdevices, extra = enumerate_subdevices(_FakeSession({}), {}, oic_res_links=[])
|
||||
assert subdevices == []
|
||||
assert extra == {}
|
||||
|
||||
|
||||
def test_enumerate_indexed_materializes_candidate_regardless_of_content():
|
||||
"""enumerate_sub_units itself has no way to tell a real sibling from an
|
||||
"""enumerate_subdevices itself has no way to tell a real sibling from an
|
||||
unused slot that merely answers the same shape (HJcom'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
|
||||
@@ -293,8 +293,8 @@ def test_enumerate_indexed_materializes_candidate_regardless_of_content():
|
||||
('device', '2'): [_DEVCOL_REP, {'href': '/power/vs/2', 'rep': {}},
|
||||
{'href': '/configuration/vs/2', 'rep': {'region': '123'}}],
|
||||
})
|
||||
units, extra = enumerate_sub_units(sess, {}, oic_res)
|
||||
assert [(u.kind, u.key) for u in units] == [('indexed', '2')]
|
||||
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'}}
|
||||
|
||||
|
||||
@@ -304,7 +304,7 @@ def test_enumerate_probe_log_reports_every_attempt():
|
||||
('device', '1'): [_DEVCOL_REP, {'href': '/mode/vs/1', 'rep': {'m': 1}}],
|
||||
})
|
||||
probes: dict[str, bool] = {}
|
||||
enumerate_sub_units(sess, {}, oic_res, probe_log=probes.__setitem__)
|
||||
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}
|
||||
@@ -315,7 +315,7 @@ def test_enumerate_probe_log_reports_empty_answers_too():
|
||||
posture the speculative-probe code this replaced used to document
|
||||
directly in identity.py)."""
|
||||
probes: dict[str, bool] = {}
|
||||
enumerate_sub_units(_FakeSession({}), {}, oic_res_links=[], probe_log=probes.__setitem__)
|
||||
enumerate_subdevices(_FakeSession({}), {}, oic_res_links=[], probe_log=probes.__setitem__)
|
||||
assert probes == {
|
||||
'/device/1': False, '/device/2': False, '/multidevice/vs/0': False,
|
||||
}
|
||||
@@ -328,8 +328,8 @@ def test_enumerate_multidevice_vs_0_is_captured_for_diagnostics_not_a_gate():
|
||||
sess = _FakeSession({
|
||||
('multidevice', 'vs', '0'): {'x.com.samsung.da.numofsubdevice': '2'},
|
||||
})
|
||||
units, extra = enumerate_sub_units(sess, {}, oic_res_links=[])
|
||||
assert units == [] # no /device/<n> or subdevice id answered
|
||||
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'},
|
||||
}
|
||||
@@ -346,8 +346,8 @@ def test_enumerate_both_patterns_checked_independently():
|
||||
(_UUID, 'device', '0'): [_DEVCOL_REP, {'href': '/mode/vs/0', 'rep': {'m': 1}}],
|
||||
('device', '1'): [_DEVCOL_REP, {'href': '/mode/vs/1', 'rep': {'m': 2}}],
|
||||
})
|
||||
units, extra = enumerate_sub_units(sess, resources, oic_res)
|
||||
assert sorted((u.kind, u.key) for u in units) == [
|
||||
subdevices, extra = enumerate_subdevices(sess, resources, oic_res)
|
||||
assert sorted((u.kind, u.key) for u in subdevices) == [
|
||||
('indexed', '1'), ('prefixed', _UUID),
|
||||
]
|
||||
|
||||
@@ -363,7 +363,7 @@ class _FakeRegistry:
|
||||
self.pattern_capabilities = list(pattern_capabilities)
|
||||
|
||||
|
||||
def test_discover_partitioned_binds_main_and_sub_unit_separately():
|
||||
def test_discover_partitioned_binds_main_and_subdevice_separately():
|
||||
mode_cap = Capability(
|
||||
href='/mode/vs/0',
|
||||
entities=(BinarySensorDesc(key='mode', field='m'),),
|
||||
@@ -371,7 +371,7 @@ def test_discover_partitioned_binds_main_and_sub_unit_separately():
|
||||
registry = {'/mode/vs/0': [mode_cap]}
|
||||
reg = _FakeRegistry('airconditioner', registry)
|
||||
|
||||
unit1 = _indexed('1')
|
||||
sub1 = _indexed('1')
|
||||
resources = {
|
||||
'/mode/vs/0': {'m': 'main'},
|
||||
'/mode/vs/1': {'m': 'sibling'},
|
||||
@@ -381,35 +381,35 @@ def test_discover_partitioned_binds_main_and_sub_unit_separately():
|
||||
return reg
|
||||
|
||||
bound, device_type_name, materialized, skipped = discover_partitioned(
|
||||
resources, [unit1], resolve, fallback_capabilities={},
|
||||
resources, [sub1], resolve, fallback_capabilities={},
|
||||
)
|
||||
|
||||
assert device_type_name == 'airconditioner'
|
||||
assert materialized == [unit1]
|
||||
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'].sub_unit == MAIN
|
||||
assert by_href['/mode/vs/1'].sub_unit == unit1
|
||||
assert by_href['/mode/vs/0'].subdevice == MAIN
|
||||
assert by_href['/mode/vs/1'].subdevice == sub1
|
||||
|
||||
|
||||
def test_discover_partitioned_main_pass_excludes_sub_unit_hrefs_from_unbound():
|
||||
"""A sub-unit's own /mode/vs/1 must not land in the main pass's unbound
|
||||
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 units.
|
||||
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
|
||||
sub-unit pass over their own canonical views have something to claim
|
||||
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 sub-unit pass resolves it via its own canonical '/mode/vs/0'."""
|
||||
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]})
|
||||
unit1 = _indexed('1')
|
||||
sub1 = _indexed('1')
|
||||
resources = {
|
||||
'/mode/vs/0': {'m': 'main'},
|
||||
'/mode/vs/1': {'m': 'sibling'},
|
||||
@@ -417,47 +417,47 @@ def test_discover_partitioned_main_pass_excludes_sub_unit_hrefs_from_unbound():
|
||||
|
||||
unbound = []
|
||||
discover_partitioned(
|
||||
resources, [unit1], lambda r: reg, fallback_capabilities={},
|
||||
resources, [sub1], lambda r: reg, fallback_capabilities={},
|
||||
log=unbound.append,
|
||||
)
|
||||
assert unbound == []
|
||||
|
||||
|
||||
def test_discover_partitioned_sub_unit_resolves_its_own_registry():
|
||||
"""A sub-unit reporting its own /information/vs/0 resolves its own
|
||||
device type (jhkwon19's wall unit: TP2X_FAC_BORA_RAC_21K -> RAC ->
|
||||
def test_discover_partitioned_subdevice_resolves_its_own_registry():
|
||||
"""A subdevice reporting its own /information/vs/0 resolves its own
|
||||
device type (jhkwon19's 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]})
|
||||
|
||||
unit1 = _indexed('1')
|
||||
sub1 = _indexed('1')
|
||||
resources = {'/mode/vs/0': {'x': 1}, '/mode/vs/1': {'x': 2}}
|
||||
|
||||
def resolve(view):
|
||||
# The sub-unit's canonical view is exactly {'/mode/vs/0': {'x': 2}}.
|
||||
# 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, [unit1], resolve, fallback_capabilities={},
|
||||
resources, [sub1], resolve, fallback_capabilities={},
|
||||
)
|
||||
assert device_type_name == 'main_type'
|
||||
assert materialized == [unit1]
|
||||
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_sub_unit_falls_back_to_master_registry():
|
||||
"""A sub-unit that reports no identity of its own resolves through the
|
||||
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]})
|
||||
unit1 = _indexed('1')
|
||||
sub1 = _indexed('1')
|
||||
resources = {
|
||||
'/information/vs/0': {'model': 'main'},
|
||||
'/mode/vs/0': {'x': 1},
|
||||
@@ -468,16 +468,16 @@ def test_discover_partitioned_sub_unit_falls_back_to_master_registry():
|
||||
return main_reg if view.get('/information/vs/0') else None
|
||||
|
||||
bound, _, materialized, skipped = discover_partitioned(
|
||||
resources, [unit1], resolve, fallback_capabilities={},
|
||||
resources, [sub1], resolve, fallback_capabilities={},
|
||||
)
|
||||
assert materialized == [unit1]
|
||||
assert materialized == [sub1]
|
||||
assert skipped == []
|
||||
hrefs = {b.href for b in bound}
|
||||
assert '/mode/vs/1' in hrefs
|
||||
|
||||
|
||||
def test_discover_partitioned_no_sub_units_matches_plain_discover():
|
||||
"""For a device with no sub-units this must be exactly the single
|
||||
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
|
||||
@@ -506,7 +506,7 @@ def test_discover_partitioned_no_sub_units_matches_plain_discover():
|
||||
def test_discover_partitioned_skips_candidate_with_no_live_primary_entity():
|
||||
"""A candidate whose only populated entity is diagnostic-category
|
||||
doesn't count -- exactly HJcom's /device/2 shape (an alarm_code
|
||||
sensor reading something even though the unit itself is empty)."""
|
||||
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'),),
|
||||
@@ -523,11 +523,11 @@ def test_discover_partitioned_skips_candidate_with_no_live_primary_entity():
|
||||
)
|
||||
assert materialized == []
|
||||
assert len(skipped) == 1
|
||||
assert skipped[0].sub_unit == unit2
|
||||
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.sub_unit != unit2 for b in bound)
|
||||
assert all(b.subdevice != unit2 for b in bound)
|
||||
|
||||
|
||||
def test_discover_partitioned_materializes_candidate_with_live_primary_entity():
|
||||
@@ -545,20 +545,20 @@ def test_discover_partitioned_materializes_candidate_with_live_primary_entity():
|
||||
reg = _FakeRegistry(
|
||||
'airconditioner', {'/mode/vs/0': [climate_cap], '/alarms/vs/0': [diag_cap]},
|
||||
)
|
||||
unit1 = _indexed('1')
|
||||
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 unit live
|
||||
'/alarms/vs/1': {}, # empty -- not what makes this subdevice live
|
||||
}
|
||||
|
||||
bound, _, materialized, skipped = discover_partitioned(
|
||||
resources, [unit1], lambda r: reg, fallback_capabilities={},
|
||||
resources, [sub1], lambda r: reg, fallback_capabilities={},
|
||||
)
|
||||
assert materialized == [unit1]
|
||||
assert materialized == [sub1]
|
||||
assert skipped == []
|
||||
hrefs = {b.href for b in bound if b.sub_unit == unit1}
|
||||
hrefs = {b.href for b in bound if b.subdevice == sub1}
|
||||
assert hrefs == {'/mode/vs/1', '/alarms/vs/1'}
|
||||
|
||||
|
||||
+13
-13
@@ -1,8 +1,8 @@
|
||||
"""Corpus-wide invariant: adapter._key must be unique across every bound
|
||||
entity that would actually be registered as an HA entity, for every fixture
|
||||
in the corpus -- issue #177's whole SubUnit/key_prefix design exists to
|
||||
in the corpus -- issue #177's whole Subdevice/key_prefix design exists to
|
||||
protect this (see DESIGN-177.md section 3/6). Run over the entire fixture
|
||||
set, not just the two new sub-unit fixtures, so a future dump -- sub-unit-
|
||||
set, not just the two new subdevice fixtures, so a future dump -- subdevice-
|
||||
capable or not -- exercises it automatically.
|
||||
"""
|
||||
from collections import Counter
|
||||
@@ -25,13 +25,13 @@ class _FakeCoordinator:
|
||||
_is_included -- the same one-time entity-creation gate every platform's
|
||||
async_setup_entry runs (see entity.py's own module docstring)."""
|
||||
|
||||
def __init__(self, resources: dict[str, dict], sub_units):
|
||||
def __init__(self, resources: dict[str, dict], subdevices):
|
||||
self.last_resources = resources
|
||||
self._sub_units = list(sub_units)
|
||||
self._subdevices = list(subdevices)
|
||||
|
||||
def canonical_resources(self, sub_unit):
|
||||
from custom_components.localthings.registry.subunits import canonical_view
|
||||
return canonical_view(sub_unit, self.last_resources, self._sub_units)
|
||||
def canonical_resources(self, subdevice):
|
||||
from custom_components.localthings.registry.subdevices import canonical_view
|
||||
return canonical_view(subdevice, self.last_resources, self._subdevices)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('name', _FIXTURE_NAMES)
|
||||
@@ -62,21 +62,21 @@ def test_key_is_unique_across_all_bound_entities(name):
|
||||
dupes = {k: n for k, n in Counter(keys).items() if n > 1}
|
||||
assert not dupes, (
|
||||
f"{name}: duplicate (platform, _key) values across {len(included)} "
|
||||
f"included entities (materialized sub-units: "
|
||||
f"included entities (materialized subdevices: "
|
||||
f"{[su.key for su in materialized]}): {dupes}"
|
||||
)
|
||||
|
||||
|
||||
def test_sub_unit_capable_fixtures_actually_exercise_a_sub_unit():
|
||||
"""A meta-check on the test above: if both sub-unit fixtures somehow
|
||||
stopped materializing any sub-unit (a regression in enumeration or the
|
||||
def test_subdevice_capable_fixtures_actually_exercise_a_subdevice():
|
||||
"""A meta-check on the test above: if both subdevice fixtures somehow
|
||||
stopped materializing any subdevice (a regression in enumeration or the
|
||||
materialization gate), the corpus-wide uniqueness test above would keep
|
||||
passing vacuously -- it never gets to check a single collision. Assert
|
||||
the two fixtures this design added actually produce a materialized
|
||||
sub-unit, so that silent-vacuous-pass failure mode is caught here
|
||||
subdevice, so that silent-vacuous-pass failure mode is caught here
|
||||
instead."""
|
||||
for name in ('airconditioner_artik051_dongle_fac_18k',
|
||||
'airconditioner_fac_bora_2in1'):
|
||||
resources, oic_res, seeds = _load_device_full(name)
|
||||
_, materialized, _, _, _ = _discover_full(resources, oic_res, seeds)
|
||||
assert materialized, f"{name}: expected at least one materialized sub-unit"
|
||||
assert materialized, f"{name}: expected at least one materialized subdevice"
|
||||
Reference in new issue
Block a user