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:
Marc Billow
2026-08-09 00:58:26 +00:00
parent b6f0bc22cb
commit 5e2c23a62d
6 changed files with 631 additions and 3 deletions
@@ -225,6 +225,7 @@ _OIC_TYPE_TO_KEY: dict[str, str] = {
"oic.d.dishwasher": "dishwasher",
"oic.d.dryer": "dryer",
"oic.d.oven": "oven",
"oic.d.range": "range", # issue #324 -- oven+cooktop combo, no /information/vs/0
"oic.d.refrigerator": "refrigerator",
"oic.d.krefrigerator": "refrigerator", # issue #328 -- kimchi refrigerator
"oic.d.washer": "washer",
+8 -1
View File
@@ -70,7 +70,7 @@ class FakeCoapSession:
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
data, HA-free -- mirrors exactly what LocalThingsCoordinator does across
_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
(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)`:
- `bound`: every BoundEntity, main + every materialized subdevice.
- `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,
resolve,
CAPABILITIES,
oic_device_types=device_types,
)
return bound, materialized, skipped, full_resources, device_type_name
+39
View File
@@ -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
+21 -2
View File
@@ -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
(enumerate_subdevices + discover_partitioned, issue #177) instead of a
single discover() call, so the golden for a composite-device fixture
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 tests.conftest import _discover_full, _load_device_full
@@ -1146,6 +1150,7 @@ def _new_subdevice_aware_state_keys(name):
resources,
oic_res,
seeds,
device_types,
)
state = flatten(bound, full_resources)
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():
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