Merge pull request #225 from atc722/agent/qooker-support
Route Samsung Bespoke Qooker to microwave registry
This commit is contained in:
@@ -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
@@ -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"
|
||||
]
|
||||
}
|
||||
+183
File diff suppressed because one or more lines are too long
+72
-7
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user