Add device support for dual-cavity range TP1X_DA-KS-RANGE-0101X (issue #324)
This board (NE63T8751SG/AA-class) reports no /information/vs/0 at all -- the modelNum-based routing fallback has nothing to read -- so it fell back to 'unknown' and lost the whole range registry (oven mode/setpoint/ door/connected, cooktop monitoring). /oic/d does carry oic.d.range, though, so this is a routing fix, not a new capability: adds 'oic.d.range' to _OIC_TYPE_TO_KEY. The second oven cavity is a genuine Pattern A indexed subdevice at /device/1 (issue #177's mechanism) -- once routing resolves the master to the range registry, the same registry already applies to the subdevice's canonical view and every href on both binds with zero gaps. _discover_full gains an optional device_types param (default (), every other fixture unaffected) so a fixture that can only route via /oic/d can exercise the same subdevice-aware pipeline the other composite fixtures already do.
This commit is contained in:
@@ -225,6 +225,7 @@ _OIC_TYPE_TO_KEY: dict[str, str] = {
|
|||||||
"oic.d.dishwasher": "dishwasher",
|
"oic.d.dishwasher": "dishwasher",
|
||||||
"oic.d.dryer": "dryer",
|
"oic.d.dryer": "dryer",
|
||||||
"oic.d.oven": "oven",
|
"oic.d.oven": "oven",
|
||||||
|
"oic.d.range": "range", # issue #324 -- oven+cooktop combo, no /information/vs/0
|
||||||
"oic.d.refrigerator": "refrigerator",
|
"oic.d.refrigerator": "refrigerator",
|
||||||
"oic.d.krefrigerator": "refrigerator", # issue #328 -- kimchi refrigerator
|
"oic.d.krefrigerator": "refrigerator", # issue #328 -- kimchi refrigerator
|
||||||
"oic.d.washer": "washer",
|
"oic.d.washer": "washer",
|
||||||
|
|||||||
+8
-1
@@ -70,7 +70,7 @@ class FakeCoapSession:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
def _discover_full(resources: dict[str, dict], oic_res, seeds: dict[str, list]):
|
def _discover_full(resources: dict[str, dict], oic_res, seeds: dict[str, list], device_types=()):
|
||||||
"""Run the *whole* subdevice-aware discovery pipeline against fixture
|
"""Run the *whole* subdevice-aware discovery pipeline against fixture
|
||||||
data, HA-free -- mirrors exactly what LocalThingsCoordinator does across
|
data, HA-free -- mirrors exactly what LocalThingsCoordinator does across
|
||||||
_enumerate_subdevices_blocking + _run_discovery (issue #177), so a test
|
_enumerate_subdevices_blocking + _run_discovery (issue #177), so a test
|
||||||
@@ -78,6 +78,12 @@ def _discover_full(resources: dict[str, dict], oic_res, seeds: dict[str, list]):
|
|||||||
it. See the adding-device-support skill's section 2 for the plain
|
it. See the adding-device-support skill's section 2 for the plain
|
||||||
(non-subdevice) equivalent this extends.
|
(non-subdevice) equivalent this extends.
|
||||||
|
|
||||||
|
`device_types` is the master's own /oic/d `rt` (see
|
||||||
|
discover_partitioned's `oic_device_types` param) -- only needed for a
|
||||||
|
board with no /information/vs/0 at all to route from (issue #324's
|
||||||
|
range, whose modelNum-based fallback has nothing to read), so it
|
||||||
|
defaults to () for every fixture that resolves by board token instead.
|
||||||
|
|
||||||
Returns `(bound, materialized, skipped, full_resources, device_type_name)`:
|
Returns `(bound, materialized, skipped, full_resources, device_type_name)`:
|
||||||
- `bound`: every BoundEntity, main + every materialized subdevice.
|
- `bound`: every BoundEntity, main + every materialized subdevice.
|
||||||
- `materialized`/`skipped`: Subdevice / SkippedSubdevice lists straight from
|
- `materialized`/`skipped`: Subdevice / SkippedSubdevice lists straight from
|
||||||
@@ -101,6 +107,7 @@ def _discover_full(resources: dict[str, dict], oic_res, seeds: dict[str, list]):
|
|||||||
candidates,
|
candidates,
|
||||||
resolve,
|
resolve,
|
||||||
CAPABILITIES,
|
CAPABILITIES,
|
||||||
|
oic_device_types=device_types,
|
||||||
)
|
)
|
||||||
return bound, materialized, skipped, full_resources, device_type_name
|
return bound, materialized, skipped, full_resources, device_type_name
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"state_keys": [
|
||||||
|
"alarm_code",
|
||||||
|
"child_lock",
|
||||||
|
"cloud_connected",
|
||||||
|
"cook_time",
|
||||||
|
"cooktop_on_alert",
|
||||||
|
"cooktop_running_state",
|
||||||
|
"current_temp_c",
|
||||||
|
"cycle_active",
|
||||||
|
"door_open",
|
||||||
|
"energy_saving",
|
||||||
|
"finish_time",
|
||||||
|
"firmware_update",
|
||||||
|
"lamp",
|
||||||
|
"machine_state",
|
||||||
|
"operation_time_minutes",
|
||||||
|
"oven_mode",
|
||||||
|
"oven_setpoint",
|
||||||
|
"oven_state",
|
||||||
|
"power_switch",
|
||||||
|
"progress_percentage",
|
||||||
|
"remote_control",
|
||||||
|
"sound",
|
||||||
|
"subdevice1_cloud_connected",
|
||||||
|
"subdevice1_cook_time",
|
||||||
|
"subdevice1_current_temp_c",
|
||||||
|
"subdevice1_cycle_active",
|
||||||
|
"subdevice1_finish_time",
|
||||||
|
"subdevice1_machine_state",
|
||||||
|
"subdevice1_operation_time_minutes",
|
||||||
|
"subdevice1_oven_mode",
|
||||||
|
"subdevice1_oven_setpoint",
|
||||||
|
"subdevice1_oven_state",
|
||||||
|
"subdevice1_progress_percentage",
|
||||||
|
"subdevice1_sound",
|
||||||
|
"warming_center_state"
|
||||||
|
]
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
@@ -1132,12 +1132,16 @@ def test_registry_reproduces_golden_state_keys_for_airconditioner_fac_bora():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _new_subdevice_aware_state_keys(name):
|
def _new_subdevice_aware_state_keys(name, device_types=()):
|
||||||
"""Like _new_state_keys, but runs the full subdevice-aware pipeline
|
"""Like _new_state_keys, but runs the full subdevice-aware pipeline
|
||||||
(enumerate_subdevices + discover_partitioned, issue #177) instead of a
|
(enumerate_subdevices + discover_partitioned, issue #177) instead of a
|
||||||
single discover() call, so the golden for a composite-device fixture
|
single discover() call, so the golden for a composite-device fixture
|
||||||
captures every materialized subdevice's keys
|
captures every materialized subdevice's keys
|
||||||
(subdevice1_-/subdevice_<uuid>_-prefixed), not just the master's."""
|
(subdevice1_-/subdevice_<uuid>_-prefixed), not just the master's.
|
||||||
|
|
||||||
|
`device_types` threads through to discover_partitioned's own
|
||||||
|
`oic_device_types` -- needed for a board with no /information/vs/0 at
|
||||||
|
all to route from (issue #324's range)."""
|
||||||
from custom_components.localthings.registry.adapter import flatten
|
from custom_components.localthings.registry.adapter import flatten
|
||||||
from tests.conftest import _discover_full, _load_device_full
|
from tests.conftest import _discover_full, _load_device_full
|
||||||
|
|
||||||
@@ -1146,6 +1150,7 @@ def _new_subdevice_aware_state_keys(name):
|
|||||||
resources,
|
resources,
|
||||||
oic_res,
|
oic_res,
|
||||||
seeds,
|
seeds,
|
||||||
|
device_types,
|
||||||
)
|
)
|
||||||
state = flatten(bound, full_resources)
|
state = flatten(bound, full_resources)
|
||||||
return sorted(state.keys())
|
return sorted(state.keys())
|
||||||
@@ -1465,6 +1470,20 @@ def test_registry_reproduces_golden_state_keys_for_refrigerator_winecellar():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_registry_reproduces_golden_state_keys_for_range_tp1x_da_ks_range_0101x():
|
||||||
|
"""Dual-cavity range (issue #324): no /information/vs/0 at all, so
|
||||||
|
routing depends entirely on /oic/d's oic.d.range -- and the second
|
||||||
|
cavity is a genuine Pattern A indexed subdevice at /device/1."""
|
||||||
|
name = "range_tp1x_da_ks_range_0101x"
|
||||||
|
golden = json.loads((GOLDEN / f"{name}.json").read_text())
|
||||||
|
state_keys = _new_subdevice_aware_state_keys(name, device_types=("oic.wk.d", "oic.d.range"))
|
||||||
|
assert set(state_keys) == set(golden["state_keys"]), (
|
||||||
|
f"state_keys mismatch:\n"
|
||||||
|
f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n"
|
||||||
|
f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_resources_from_batch_preferred_over_flat():
|
def test_resources_from_batch_preferred_over_flat():
|
||||||
from tests.conftest import _resources_from_dump
|
from tests.conftest import _resources_from_dump
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
"""Dual-cavity range (TP1X_DA-KS-RANGE-0101X, NE63T8751SG/AA-class, issue
|
||||||
|
#324): no /information/vs/0 at all, so the modelNum-based fallback has
|
||||||
|
nothing to read -- routing depends entirely on /oic/d's own device type
|
||||||
|
(`oic.d.range`), read separately from the /device/0 batch (see
|
||||||
|
registry/identity.py and by_type/__init__.py's `_OIC_TYPE_TO_KEY`).
|
||||||
|
|
||||||
|
The second oven cavity answers as an indexed sibling at /device/1 (Pattern
|
||||||
|
A, same mechanism as the AC family's issue #177 fixtures) -- the master's
|
||||||
|
/mode/vs/0 (defaultMode 'UpperConvectionBake') is the upper cavity, the
|
||||||
|
subdevice's canonical /mode/vs/0 (defaultMode 'LowerConvectionBake') is the
|
||||||
|
lower one.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from custom_components.localthings.registry.adapter import flatten
|
||||||
|
from custom_components.localthings.registry.by_type import for_device_by_oic_type, resolve
|
||||||
|
from tests.conftest import _discover_full, _load_device_full
|
||||||
|
|
||||||
|
FIXTURE = "range_tp1x_da_ks_range_0101x"
|
||||||
|
DEVICE_TYPES = ("oic.wk.d", "oic.d.range")
|
||||||
|
|
||||||
|
|
||||||
|
def _discover():
|
||||||
|
resources, oic_res, seeds = _load_device_full(FIXTURE)
|
||||||
|
return _discover_full(resources, oic_res, seeds, DEVICE_TYPES)
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolves_via_oic_type():
|
||||||
|
reg = for_device_by_oic_type(DEVICE_TYPES)
|
||||||
|
assert reg is not None and reg.name == "range"
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolves_via_the_full_resolve_entrypoint():
|
||||||
|
resources, _oic_res, _seeds = _load_device_full(FIXTURE)
|
||||||
|
reg = resolve(resources, device_types=DEVICE_TYPES)
|
||||||
|
assert reg is not None and reg.name == "range"
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_unbound_hrefs():
|
||||||
|
"""Every resource in the issue #324 dump binds or is ignored, on both
|
||||||
|
the master (upper cavity) and the materialized second cavity -- clears
|
||||||
|
the coverage-gap repair."""
|
||||||
|
resources, oic_res, seeds = _load_device_full(FIXTURE)
|
||||||
|
unbound = []
|
||||||
|
from custom_components.localthings.registry.registry import CAPABILITIES
|
||||||
|
from custom_components.localthings.registry.subdevices import (
|
||||||
|
discover_partitioned,
|
||||||
|
enumerate_subdevices,
|
||||||
|
)
|
||||||
|
from tests.conftest import FakeCoapSession
|
||||||
|
|
||||||
|
sess = FakeCoapSession(seeds)
|
||||||
|
candidates, extra = enumerate_subdevices(sess, resources, oic_res)
|
||||||
|
full_resources = {**resources, **extra}
|
||||||
|
discover_partitioned(
|
||||||
|
full_resources,
|
||||||
|
candidates,
|
||||||
|
resolve,
|
||||||
|
CAPABILITIES,
|
||||||
|
log=unbound.append,
|
||||||
|
oic_device_types=DEVICE_TYPES,
|
||||||
|
)
|
||||||
|
assert unbound == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_second_cavity_materializes_as_an_indexed_subdevice():
|
||||||
|
_bound, materialized, skipped, _full_resources, device_type_name = _discover()
|
||||||
|
assert device_type_name == "range"
|
||||||
|
assert skipped == []
|
||||||
|
assert [(s.kind, s.key) for s in materialized] == [("indexed", "1")]
|
||||||
|
assert materialized[0].seed_path == ("device", "1")
|
||||||
|
|
||||||
|
|
||||||
|
def test_both_cavities_expose_distinct_oven_state():
|
||||||
|
bound, _materialized, _skipped, full_resources, _name = _discover()
|
||||||
|
state = flatten(bound, full_resources)
|
||||||
|
for key in ("oven_mode", "oven_setpoint", "oven_state", "machine_state", "door_open"):
|
||||||
|
assert key in state, key
|
||||||
|
# Second cavity's entities carry the subdevice1_ prefix (adapter._key) --
|
||||||
|
# the master's own keys stay unprefixed, so both cavities get distinct
|
||||||
|
# unique_ids rather than colliding on the same entity key.
|
||||||
|
assert "subdevice1_oven_mode" in state
|
||||||
|
assert "subdevice1_oven_setpoint" in state
|
||||||
|
|
||||||
|
|
||||||
|
def test_cooktop_monitoring_present_on_the_master_only():
|
||||||
|
"""The cooktop half belongs to the appliance as a whole, not either oven
|
||||||
|
cavity -- /cooktopmonitoring/vs/0 has no per-cavity index."""
|
||||||
|
bound, _materialized, _skipped, full_resources, _name = _discover()
|
||||||
|
state = flatten(bound, full_resources)
|
||||||
|
assert "cooktop_running_state" in state
|
||||||
|
assert "warming_center_state" in state
|
||||||
|
assert "subdevice1_cooktop_running_state" not in state
|
||||||
Reference in New Issue
Block a user