Merge pull request #225 from atc722/agent/qooker-support

Route Samsung Bespoke Qooker to microwave registry
This commit is contained in:
Marc Billow
2026-08-02 12:22:10 -05:00
committed by GitHub
6 changed files with 341 additions and 22 deletions
@@ -296,13 +296,16 @@ def for_device_by_model(model_num: str, description: str) -> Optional[DeviceRegi
def for_device_by_resources(resources: dict[str, dict]) -> Optional[DeviceRegistry]:
"""Detect a device family from a distinctive local-resource signature.
For boards that ship no ``/information/vs/0`` at all, leaving
`for_device_by_model` nothing to read. Some newer cooktops are the
This runs first as an override path for non-standard devices, not because
resource signatures are inherently more trustworthy than OIC/model
metadata. It also types boards that ship no ``/information/vs/0`` at all,
leaving `for_device_by_model` nothing to read. Some newer cooktops are the
original case: their mode resource still identifies them, carrying a
DeviceType option and multiple per-burner OperationState options.
Require two independent shapes for every signature here, never one, so
an unrelated family's ``/mode/vs/0`` isn't misclassified.
putting this ahead of OIC/model metadata cannot let a common resource
misclassify an unrelated family.
"""
mode = resources.get('/mode/vs/0', {})
options = mode.get('x.com.samsung.da.options') or ()
@@ -325,9 +328,14 @@ def for_device_by_resources(resources: dict[str, dict]) -> Optional[DeviceRegist
# (issue #74's NE63B8411SS, issue #172's ME8000T -- the resource is simply
# absent from the dump, not just empty) can't be matched via
# for_device_by_model's modelNum tokens either. Mode vocabulary alongside
# the oven cavity resource (/oven/vs/0) is a safe signature.
# the oven cavity resource (/oven/vs/0) is a safe two-resource signature;
# it also corrects Qooker's generic oic.d.oven / OVEN metadata (issue
# PR #225) when resource detection runs before metadata.
supported_modes = mode.get('x.com.samsung.da.supportedModes') or ()
if '/oven/vs/0' in resources:
if not isinstance(supported_modes, (list, tuple)):
supported_modes = ()
cavity = resources.get('/oven/vs/0')
if isinstance(cavity, dict):
if any(
m in supported_modes
for m in ('MicroWave', 'MicroWaveGrill', 'MicroWaveConvection')
@@ -349,13 +357,13 @@ def resolve(
flow's probe and the golden-regression harness all call this, so the
order can't drift between what ships and what the tests assert.
`device_types` (/oic/d's `rt`, read separately from the /device/0 dump --
see registry/identity.py) is the primary signal when present: the device
naming its own type beats parsing board part numbers. Falls back to model
strings (`for_device_by_model`), then a distinctive resource signature
(`for_device_by_resources`) for boards that report no /information/vs/0
at all -- both unchanged from before /oic/d was ever consulted, since most
hardware still doesn't populate it usefully.
Distinctive resource signatures run first because they describe the live
capability surface a registry must bind. They are deliberately strict in
`for_device_by_resources`: each requires multiple independent details, so
this can correct misleading metadata (Qooker's generic ``oic.d.oven``)
without a common href overriding an unrelated family. When no signature
matches, `/oic/d`'s `rt` (read separately from the /device/0 dump -- see
registry/identity.py) wins over model-string parsing.
`/otninformation/vs/0`'s oneUiVersion is deliberately not consulted. It
reads like the obvious signal -- the device naming its own type, e.g.
@@ -366,10 +374,10 @@ def resolve(
"""
info = resources.get('/information/vs/0', {})
return (
for_device_by_oic_type(device_types)
for_device_by_resources(resources)
or for_device_by_oic_type(device_types)
or for_device_by_model(
info.get('x.com.samsung.da.modelNum', ''),
info.get('x.com.samsung.da.description', ''),
)
or for_device_by_resources(resources)
)
+23
View File
@@ -0,0 +1,23 @@
{
"state_keys": [
"alarm_code",
"cavity_state",
"child_lock",
"cloud_connected",
"cook_time",
"cooking_mode",
"current_temp_c",
"cycle_active",
"door_open",
"energy_kwh",
"finish_time",
"firmware_update",
"machine_state",
"operation_time_minutes",
"power_level",
"power_switch",
"progress_percentage",
"setpoint",
"sound"
]
}
File diff suppressed because one or more lines are too long
+72 -7
View File
@@ -716,22 +716,63 @@ class TestResolve:
assert reg is not None
assert reg.name == 'refrigerator'
def test_prefers_model_strings_over_resource_signature(self, all_device_fixtures):
"""Every fixture with usable model strings resolves the same way
through `resolve` as through `for_device_by_model` directly."""
def test_prefers_resource_signatures_over_model_strings(self, all_device_fixtures):
"""A strong live-resource signature wins over model metadata.
Across the fixture corpus Qooker is the only intentional disagreement:
its OVEN model token says oven while its /oven + MicroWave surface says
microwave. Locking the disagreement set keeps resource-first routing
from silently becoming greedy as new signatures or fixtures land.
"""
from custom_components.localthings.registry.by_type import (
resolve, for_device_by_model,
resolve, for_device_by_model, for_device_by_resources,
)
disagreements = {}
for name, resources in all_device_fixtures.items():
info = resources.get('/information/vs/0', {})
by_resources = for_device_by_resources(resources)
by_model = for_device_by_model(
info.get('x.com.samsung.da.modelNum', ''),
info.get('x.com.samsung.da.description', ''),
)
if by_model is not None:
assert resolve(resources) is by_model, name
if by_resources is None or by_model is None:
continue
assert resolve(resources) is by_resources, name
if by_resources is not by_model:
disagreements[name] = (by_resources.name, by_model.name)
def test_falls_back_to_resource_signature(self, all_device_fixtures):
assert disagreements == {
'qooker_mw7500a': ('microwave', 'oven'),
}
def test_qooker_microwave_surface_overrides_generic_oven_oic_type(self):
"""MW7500A declares oic.d.oven and carries an OVEN board token, but
its verified local API exposes MicroWave mode on an oven cavity. That
strong two-resource signature must win ahead of generic OIC metadata."""
from custom_components.localthings.registry.by_type import (
for_device_by_model,
for_device_by_oic_type,
for_device_by_resources,
resolve,
)
from tests.conftest import _load_device
resources = _load_device('qooker_mw7500a')
info = resources['/information/vs/0']
by_resources = for_device_by_resources(resources)
by_oic = for_device_by_oic_type(('oic.wk.d', 'oic.d.oven'))
by_model = for_device_by_model(
info['x.com.samsung.da.modelNum'],
info['x.com.samsung.da.description'],
)
reg = resolve(resources, device_types=('oic.wk.d', 'oic.d.oven'))
assert by_resources is not None and by_resources.name == 'microwave'
assert by_oic is not None and by_oic.name == 'oven'
assert by_model is not None and by_model.name == 'oven'
assert reg is by_resources
def test_resource_signature_types_dumps_without_information(self, all_device_fixtures):
"""The three dumps with no /information/vs/0 still type."""
from custom_components.localthings.registry.by_type import resolve
for name in ('cooktop', 'range_ne63a6511', 'range_no_info'):
@@ -838,3 +879,27 @@ class TestForDeviceByResources:
assert reg is not None
assert reg.name == 'microwave'
def test_microwave_mode_without_oven_cavity_is_not_matched(self):
"""The mode vocabulary alone is too common to preempt metadata."""
from custom_components.localthings.registry.by_type import for_device_by_resources
resources = {
'/mode/vs/0': {
'x.com.samsung.da.supportedModes': ['MicroWave'],
},
}
assert for_device_by_resources(resources) is None
def test_scalar_supported_modes_is_not_a_microwave_signature(self):
"""Malformed scalar data must not gain resource-first precedence."""
from custom_components.localthings.registry.by_type import for_device_by_resources
resources = {
'/mode/vs/0': {
'x.com.samsung.da.supportedModes': 'MicroWave',
},
'/oven/vs/0': {'x.com.samsung.da.state': 'Ready'},
}
assert for_device_by_resources(resources) is None
+16
View File
@@ -686,6 +686,22 @@ def test_registry_reproduces_golden_state_keys_for_microwave_mw7300b():
)
def test_registry_reproduces_golden_state_keys_for_qooker_mw7500a():
"""Bespoke Qooker MW7500A uses Samsung's OVEN board/type metadata but
proves its microwave semantics through MicroWave mode plus cavity
powerLevel. The routing correction must expose microwave state keys
without the misleading oven_mode/oven_state entities."""
from tests.conftest import _load_device
resources = _load_device('qooker_mw7500a')
golden = json.loads((GOLDEN / 'qooker_mw7500a.json').read_text())
state_keys = _new_state_keys('qooker_mw7500a', 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_microwave_me7500d():
"""TP1X_DA-KS-MICROWAVE-01051 plain microwave (model ME7500D, issues
#137/#142) -- the same microwave registry as MW7300B above, but this
+25 -1
View File
@@ -1,6 +1,9 @@
"""Unit tests for the microwave-family capabilities (issue #121/#66 split
into their own device type instead of being folded into oven.py)."""
from custom_components.localthings.registry.by_type import for_device_by_model
from custom_components.localthings.registry.by_type import (
for_device_by_model,
resolve,
)
from custom_components.localthings.registry.capabilities import microwave
from custom_components.localthings.registry.discovery import discover
@@ -39,6 +42,27 @@ def test_microwave_hood_fan_fixture_resolves_and_has_no_unbound_hrefs():
assert unbound == []
def test_qooker_fixture_resolves_as_microwave_and_has_no_unbound_hrefs():
"""Bespoke Qooker MW7500A is microwave-shaped despite both its OCF type
and internal board token saying oven."""
from tests.conftest import _load_device
resources = _load_device('qooker_mw7500a')
reg = resolve(resources, device_types=('oic.wk.d', 'oic.d.oven'))
assert reg is not None
assert reg.name == 'microwave'
unbound = []
bound = discover(
resources, reg.capabilities, reg.pattern_capabilities,
log=unbound.append,
)
assert unbound == []
keys = {entity.desc.key for entity in bound}
assert {'cooking_mode', 'power_level', 'setpoint'} <= keys
assert 'oven_mode' not in keys
# ---------------------------------------------------------------------------
# MICROWAVE_SETPOINT — NumberDesc with RMW write semantics
# ---------------------------------------------------------------------------