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:
@@ -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
@@ -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"
|
||||
]
|
||||
}
|
||||
+201
File diff suppressed because one or more lines are too long
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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'
|
||||
Reference in New Issue
Block a user