Add range/oven detection for boards missing /information/vs/0 (issue #74)

NE63B8411SS reports no oneUiVersion and no /information/vs/0 resource at
all, so neither for_device nor for_device_by_model's modelNum tokens have
anything to key off -- it fell through to the unknown-device fallback,
which also mis-binds /temperatures/vs/0 against fridge.py's setpoint
capability (a collision the oven family's own capabilities are normally
excluded from the global registry specifically to avoid).

Add a resources-based signature to for_device_by_resources(): 'Bake' in
/mode/vs/0's supportedModes is oven/range-exclusive vocabulary, and paired
with the /oven/vs/0 cavity resource it reliably identifies this family
even with no model info at all. Route to 'range' when a cooktop-status
resource is also present, else plain 'oven'.

This board's cooktop half also doesn't expose the per-burner
/cooktop/status/vs/0 array range.py already models -- only the coarser
/cooktopmonitoring/vs/0 summary resource. Add a read-only
COOKTOP_MONITORING capability for it (cooktop running state, warming
center state) rather than leaving it unbound.
This commit is contained in:
Marc Billow
2026-07-27 00:23:04 +00:00
parent aff7647460
commit c72b5a988e
10 changed files with 395 additions and 0 deletions
@@ -170,4 +170,18 @@ def for_device_by_resources(resources: dict[str, dict]) -> Optional[DeviceRegist
and '/hood/lamp/vs/0' in resources
):
return _REGISTRY_BY_KEY['range_hood']
# Oven/range-combo boards that report no /information/vs/0 at all
# (issue #74's NE63B8411SS -- the resource is simply absent from the
# dump, not just empty) can't be matched via for_device_by_model's
# modelNum tokens either. 'Bake' is oven/range cook-mode vocabulary that
# no other family's /mode/vs/0 uses (confirmed against the microwave,
# cooktop, and every laundry fixture), so its presence alongside the
# oven cavity resource is a safe signature. Distinguish range (has a
# cooktop half) from a plain wall oven by which cooktop-status resource,
# if any, is also present.
supported_modes = mode.get('x.com.samsung.da.supportedModes') or ()
if '/oven/vs/0' in resources and 'Bake' in supported_modes:
if '/cooktopmonitoring/vs/0' in resources or '/cooktop/status/vs/0' in resources:
return _REGISTRY_BY_KEY['range']
return _REGISTRY_BY_KEY['oven']
return None
@@ -25,5 +25,6 @@ REGISTRY = DeviceRegistry(
range_caps.COOKTOP_STATUS,
range_caps.COOKTOP_SPEC,
range_caps.COOKTOP_SAFETY,
range_caps.COOKTOP_MONITORING,
]),
)
@@ -133,3 +133,22 @@ COOKTOP_SAFETY = Capability(
value_fn=lambda v: (v or {}).get('state') == 'on'),
),
)
# Some range boards (issue #74's NE63B8411SS) report no /cooktop/status/vs/0
# burner array at all -- their local API only exposes this coarse
# monitoring resource for the cooktop half, with no per-burner detail.
# Meaning of `cooktopMonitoring` (a bare "0" on the only dump seen) and
# `warmingCenterState`'s full value set aren't confirmed, so both are
# exposed as plain sensors rather than guessed at as a switch/select --
# `supportedHoodLampStateList` has no corresponding live-state field on
# this resource, so nothing to bind it to yet.
COOKTOP_MONITORING = Capability(
href='/cooktopmonitoring/vs/0',
poll_tier='warm',
entities=(
SensorDesc(key='cooktop_running_state', field='x.com.samsung.da.cooktopRunningState',
icon='mdi:pot-steam-outline'),
SensorDesc(key='warming_center_state', field='x.com.samsung.da.warmingCenterState',
icon='mdi:heat-wave', entity_category='diagnostic'),
),
)
@@ -448,6 +448,9 @@
"poll": "Polling"
}
},
"cooktop_running_state": {
"name": "Cooktop running state"
},
"cooktop_state": {
"name": "Cooktop state"
},
@@ -592,6 +595,9 @@
"super_fine_dust": {
"name": "Super fine dust"
},
"warming_center_state": {
"name": "Warming center state"
},
"water_liters": {
"name": "Water consumption"
},
@@ -448,6 +448,9 @@
"poll": "Polling"
}
},
"cooktop_running_state": {
"name": "Loopstatus kookplaat"
},
"cooktop_state": {
"name": "Status kookplaat"
},
@@ -592,6 +595,9 @@
"super_fine_dust": {
"name": "Ultrafijnstof"
},
"warming_center_state": {
"name": "Status warmhoudzone"
},
"water_liters": {
"name": "Waterverbruik"
},
+27
View File
@@ -0,0 +1,27 @@
{
"state_keys": [
"alarm_code",
"child_lock",
"cloud_connected",
"cook_time",
"cooktop_running_state",
"current_temp_c",
"cycle_active",
"door_open",
"fast_preheat",
"finish_time",
"firmware_update",
"lamp",
"machine_state",
"natural_steam",
"operation_time_minutes",
"oven_mode",
"oven_setpoint",
"oven_state",
"power_switch",
"progress_percentage",
"remote_control",
"sound",
"warming_center_state"
]
}
File diff suppressed because one or more lines are too long
+43
View File
@@ -284,3 +284,46 @@ class TestForDeviceByResources:
reg = for_device_by_resources(resources)
assert reg is not None
assert reg.name == 'range_hood'
def test_ne63b8411ss_without_information_or_burner_status_is_range(self):
"""Issue #74: no oneUiVersion, no /information/vs/0 at all, and no
/cooktop/status/vs/0 burner array -- only /cooktopmonitoring/vs/0.
'Bake' in supportedModes plus that monitoring resource must still
route this to the range registry, not plain oven or unknown."""
from custom_components.localthings.registry.by_type import for_device_by_resources
resources = {
'/mode/vs/0': {
'x.com.samsung.da.supportedModes': ['Bake', 'Broil', 'SelfClean'],
'x.com.samsung.da.options': ['DeviceType_NE8411B-/AC0'],
},
'/oven/vs/0': {'x.com.samsung.da.state': 'Ready'},
'/cooktopmonitoring/vs/0': {'x.com.samsung.da.cooktopRunningState': 'Ready'},
}
reg = for_device_by_resources(resources)
assert reg is not None
assert reg.name == 'range'
def test_bake_without_cooktop_resource_is_plain_oven(self):
from custom_components.localthings.registry.by_type import for_device_by_resources
resources = {
'/mode/vs/0': {
'x.com.samsung.da.supportedModes': ['Bake', 'Broil'],
'x.com.samsung.da.options': ['DeviceType_SOME_OVEN'],
},
'/oven/vs/0': {'x.com.samsung.da.state': 'Ready'},
}
reg = for_device_by_resources(resources)
assert reg is not None
assert reg.name == 'oven'
def test_bake_without_oven_cavity_resource_is_not_matched(self):
"""'Bake' alone isn't enough -- the oven cavity resource must also
be present, or this falls through to None like any other unknown
shape."""
from custom_components.localthings.registry.by_type import for_device_by_resources
resources = {
'/mode/vs/0': {
'x.com.samsung.da.supportedModes': ['Bake', 'Broil'],
},
}
assert for_device_by_resources(resources) is None
+20
View File
@@ -262,6 +262,26 @@ def test_registry_reproduces_golden_state_keys_for_range():
)
def test_registry_reproduces_golden_state_keys_for_range_no_info():
"""NE63B8411SS (issue #74) -- reports no oneUiVersion *and* no
/information/vs/0 at all, so neither for_device nor
for_device_by_model has anything to key off; resolved via the 'Bake'-
in-supportedModes + /cooktopmonitoring/vs/0 signature in
for_device_by_resources. This board's local API has no per-burner
/cooktop/status/vs/0 array either -- only the coarse
/cooktopmonitoring/vs/0 monitoring resource covered by range.py's
COOKTOP_MONITORING."""
from tests.conftest import _load_device
resources = _load_device('range_no_info')
golden = json.loads((GOLDEN / 'range_no_info.json').read_text())
state_keys = _new_state_keys('range_no_info', resources)
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_registry_reproduces_golden_state_keys_for_air_purifier():
"""ARTIK051_TVTL_18K (issue #56) -- reports no oneUiVersion; resolved via
the '_TVTL_' modelNum token fallback in for_device_by_model."""
+58
View File
@@ -0,0 +1,58 @@
"""Tests for the no-/information/vs/0, no-burner-status range variant
(NE63B8411SS-class, issue #74)."""
from custom_components.localthings.registry.adapter import flatten
from custom_components.localthings.registry.by_type import for_device_by_resources
from custom_components.localthings.registry.capabilities import range as range_caps
from custom_components.localthings.registry.discovery import discover
from tests.conftest import _load_device
def _range():
resources = _load_device('range_no_info')
reg = for_device_by_resources(resources)
return reg, resources
def _state():
reg, resources = _range()
bound = discover(resources, reg.capabilities, reg.pattern_capabilities)
return flatten(bound, resources)
def test_resolves_to_range_registry():
reg, _ = _range()
assert reg is not None and reg.name == 'range'
def test_no_unbound_hrefs():
"""Every resource in the issue #74 dump binds or is ignored -- clears
the coverage-gap repair."""
reg, resources = _range()
unbound = []
discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append)
assert unbound == []
def test_no_burner_entities():
"""This board reports no /cooktop/status/vs/0, so none of range.py's
per-burner entities should appear -- only COOKTOP_MONITORING's."""
state = _state()
assert not any(k.startswith('burner_') for k in state)
def test_expected_entities_present():
state = _state()
for key in (
'power_switch', 'oven_setpoint', 'current_temp_c', 'oven_mode',
'machine_state', 'door_open', 'cloud_connected',
'cooktop_running_state', 'warming_center_state',
):
assert key in state, key
def test_cooktop_monitoring_reads_live_fields():
desc = next(e for e in range_caps.COOKTOP_MONITORING.entities if e.key == 'cooktop_running_state')
assert desc.value_fn('Ready') == 'Ready'
desc = next(e for e in range_caps.COOKTOP_MONITORING.entities if e.key == 'warming_center_state')
assert desc.value_fn('Off') == 'Off'