diff --git a/custom_components/localthings/registry/by_type/__init__.py b/custom_components/localthings/registry/by_type/__init__.py index 3b89b97..879a7c5 100644 --- a/custom_components/localthings/registry/by_type/__init__.py +++ b/custom_components/localthings/registry/by_type/__init__.py @@ -296,13 +296,17 @@ 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 - original case: their mode resource still identifies them, carrying a - DeviceType option and multiple per-burner OperationState options. + This is the highest-confidence routing signal: the live capability surface + wins over OIC/model metadata when they disagree (Qooker's ``oic.d.oven`` + declaration is the first verified case). 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 +329,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 +358,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 +375,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) ) diff --git a/tests/fixtures/golden/qooker_mw7500a.json b/tests/fixtures/golden/qooker_mw7500a.json new file mode 100644 index 0000000..0a45aa0 --- /dev/null +++ b/tests/fixtures/golden/qooker_mw7500a.json @@ -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" + ] +} diff --git a/tests/fixtures/qooker_mw7500a_device.json b/tests/fixtures/qooker_mw7500a_device.json new file mode 100644 index 0000000..0d22e53 --- /dev/null +++ b/tests/fixtures/qooker_mw7500a_device.json @@ -0,0 +1,183 @@ +{ + "meta": { + "model": "TP2X_DA-KS-OVEN-01011 (MW7500A-/KO0)", + "device_type": "microwave", + "source": "local read-only DTLS diagnostics (scrubbed)", + "note": "Samsung Bespoke Qooker declares oic.d.oven and carries an OVEN board token, but its verified local surface exposes MicroWave mode and cavity powerLevel. It binds cleanly to the microwave registry." + }, + "device0": [ + { + "href": "/configuration/vs/0", + "rep": { + "x.com.samsung.da.clockSyncRequest": "false" + } + }, + { + "href": "/connected/vs/0", + "rep": { + "x.com.samsung.da.connected": "On" + } + }, + { + "href": "/doors/vs/0", + "rep": { + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "Door", + "x.com.samsung.da.openState": "Open" + } + ] + } + }, + { + "href": "/energy/consumption/vs/0", + "rep": { + "x.com.samsung.da.instantaneousPower": "-500", + "x.com.samsung.da.instantaneousPowerUnit": "W", + "x.com.samsung.da.cumulativePower": "91700", + "x.com.samsung.da.cumulativeUnit": "Wh" + } + }, + { + "href": "/information/vs/0", + "rep": { + "x.com.samsung.da.modelNum": "TP2X_DA-KS-OVEN-01011", + "x.com.samsung.da.description": "MW7500A-/KO0", + "x.com.samsung.da.serialNum": "**REDACTED**", + "x.com.samsung.da.otnDUID": "**REDACTED**", + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "Version", + "x.com.samsung.da.type": "Software", + "x.com.samsung.da.number": "240717", + "x.com.samsung.da.newVersionAvailable": "0" + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "Version", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.number": "DE92-04576A_21090900, DE92-04428A_23051100", + "x.com.samsung.da.newVersionAvailable": "0" + } + ], + "x.com.samsung.da.diagProtocolType": "WIFI_HTTPS", + "x.com.samsung.da.diagLogType": [ + "errCode", + "dump" + ], + "x.com.samsung.da.diagDumpType": "file", + "x.com.samsung.da.diagEndPoint": "SSM", + "x.com.samsung.da.diagMnid": "0AJT", + "x.com.samsung.da.diagSetupid": "612", + "x.com.samsung.da.diagMinVersion": "1.0" + } + }, + { + "href": "/kidslock/vs/0", + "rep": { + "x.com.samsung.da.kidsLock": "Ready" + } + }, + { + "href": "/mode/vs/0", + "rep": { + "x.com.samsung.da.supportedModes": [ + "NoOperation", + "Defrost", + "MicroWave", + "AutocookCustom", + "ToastSlicedBread", + "ToastCroissant", + "ToastBagle", + "AirFryer", + "Grill", + "Deodorization" + ], + "x.com.samsung.da.modes": [ + "NoOperation" + ], + "x.com.samsung.da.options": [ + "DeviceType_MW7500A-/KO0", + "ScreenTimeOut_5min", + "TimeSystem_24", + "Sound_On" + ], + "x.com.samsung.da.modeSpec": "[{\"mode\":\"NoOperation\",\"version\":\"0100\",\"default\":\"NotSupported\",\"control\":\"NotSupported\",\"cavity\":\"Single\",\"tempMinC\":\"NotSupported\",\"tempMaxC\":\"NotSupported\",\"tempDefaultC\":\"NotSupported\",\"tempListLengthC\":\"0\",\"tempMinF\":\"NotSupported\",\"tempMaxF\":\"NotSupported\",\"tempDefaultF\":\"NotSupported\",\"tempListLengthF\":\"0\",\"timeMin\":\"NotSupported\",\"timeMax\":\"NotSupported\",\"timeDefault\":\"NotSupported\",\"probeMinC\":\"NotSupported\",\"probeMaxC\":\"NotSupported\",\"probeDefaultC\":\"NotSupported\",\"probeMinF\":\"NotSupported\",\"probeMaxF\":\"NotSupported\",\"probeDefaultF\":\"NotSupported\",\"powerDefault\":\"NotSupported\",\"powerListLength\":\"0\"},{\"mode\":\"Defrost\",\"version\":\"0100\",\"default\":\"NotSupported\",\"control\":\"Setting\",\"cavity\":\"Single\",\"tempMinC\":\"NotSupported\",\"tempMaxC\":\"NotSupported\",\"tempDefaultC\":\"NotSupported\",\"tempListLengthC\":\"0\",\"tempMinF\":\"NotSupported\",\"tempMaxF\":\"NotSupported\",\"tempDefaultF\":\"NotSupported\",\"tempListLengthF\":\"0\",\"timeMin\":\"00:00:10\",\"timeMax\":\"01:30:00\",\"timeDefault\":\"00:00:30\",\"probeMinC\":\"NotSupported\",\"probeMaxC\":\"NotSupported\",\"probeDefaultC\":\"NotSupported\",\"probeMinF\":\"NotSupported\",\"probeMaxF\":\"NotSupported\",\"probeDefaultF\":\"NotSupported\",\"powerDefault\":\"NotSupported\",\"powerListLength\":\"0\"},{\"mode\":\"MicroWave\",\"version\":\"0100\",\"default\":\"NotSupported\",\"control\":\"Setting\",\"cavity\":\"Single\",\"tempMinC\":\"NotSupported\",\"tempMaxC\":\"NotSupported\",\"tempDefaultC\":\"NotSupported\",\"tempListLengthC\":\"0\",\"tempMinF\":\"NotSupported\",\"tempMaxF\":\"NotSupported\",\"tempDefaultF\":\"NotSupported\",\"tempListLengthF\":\"0\",\"timeMin\":\"00:00:10\",\"timeMax\":\"01:30:00\",\"timeDefault\":\"00:00:30\",\"probeMinC\":\"NotSupported\",\"probeMaxC\":\"NotSupported\",\"probeDefaultC\":\"NotSupported\",\"probeMinF\":\"NotSupported\",\"probeMaxF\":\"NotSupported\",\"probeDefaultF\":\"NotSupported\",\"powerDefault\":\"700W\",\"powerListLength\":\"3\",\"powerListData\":[\"300W\",\"450W\",\"700W\"]},{\"mode\":\"AutocookCustom\",\"version\":\"0100\",\"default\":\"NotSupported\",\"control\":\"Setting\",\"cavity\":\"Single\",\"tempMinC\":\"NotSupported\",\"tempMaxC\":\"NotSupported\",\"tempDefaultC\":\"NotSupported\",\"tempListLengthC\":\"0\",\"tempMinF\":\"NotSupported\",\"tempMaxF\":\"NotSupported\",\"tempDefaultF\":\"NotSupported\",\"tempListLengthF\":\"0\",\"timeMin\":\"NotSupported\",\"timeMax\":\"NotSupported\",\"timeDefault\":\"NotSupported\",\"probeMinC\":\"NotSupported\",\"probeMaxC\":\"NotSupported\",\"probeDefaultC\":\"NotSupported\",\"probeMinF\":\"NotSupported\",\"probeMaxF\":\"NotSupported\",\"probeDefaultF\":\"NotSupported\",\"powerDefault\":\"NotSupported\",\"powerListLength\":\"0\"},{\"mode\":\"ToastSlicedBread\",\"version\":\"0100\",\"default\":\"NotSupported\",\"control\":\"Setting\",\"cavity\":\"Single\",\"tempMinC\":\"NotSupported\",\"tempMaxC\":\"NotSupported\",\"tempDefaultC\":\"NotSupported\",\"tempListLengthC\":\"0\",\"tempMinF\":\"NotSupported\",\"tempMaxF\":\"NotSupported\",\"tempDefaultF\":\"NotSupported\",\"tempListLengthF\":\"0\",\"timeMin\":\"00:00:10\",\"timeMax\":\"00:10:00\",\"timeDefault\":\"00:00:30\",\"probeMinC\":\"NotSupported\",\"probeMaxC\":\"NotSupported\",\"probeDefaultC\":\"NotSupported\",\"probeMinF\":\"NotSupported\",\"probeMaxF\":\"NotSupported\",\"probeDefaultF\":\"NotSupported\",\"powerDefault\":\"NotSupported\",\"powerListLength\":\"0\"},{\"mode\":\"ToastCroissant\",\"version\":\"0100\",\"default\":\"NotSupported\",\"control\":\"Setting\",\"cavity\":\"Single\",\"tempMinC\":\"NotSupported\",\"tempMaxC\":\"NotSupported\",\"tempDefaultC\":\"NotSupported\",\"tempListLengthC\":\"0\",\"tempMinF\":\"NotSupported\",\"tempMaxF\":\"NotSupported\",\"tempDefaultF\":\"NotSupported\",\"tempListLengthF\":\"0\",\"timeMin\":\"00:00:10\",\"timeMax\":\"00:10:00\",\"timeDefault\":\"00:00:30\",\"probeMinC\":\"NotSupported\",\"probeMaxC\":\"NotSupported\",\"probeDefaultC\":\"NotSupported\",\"probeMinF\":\"NotSupported\",\"probeMaxF\":\"NotSupported\",\"probeDefaultF\":\"NotSupported\",\"powerDefault\":\"NotSupported\",\"powerListLength\":\"0\"},{\"mode\":\"ToastBagle\",\"version\":\"0100\",\"default\":\"NotSupported\",\"control\":\"Setting\",\"cavity\":\"Single\",\"tempMinC\":\"NotSupported\",\"tempMaxC\":\"NotSupported\",\"tempDefaultC\":\"NotSupported\",\"tempListLengthC\":\"0\",\"tempMinF\":\"NotSupported\",\"tempMaxF\":\"NotSupported\",\"tempDefaultF\":\"NotSupported\",\"tempListLengthF\":\"0\",\"timeMin\":\"00:00:10\",\"timeMax\":\"00:10:00\",\"timeDefault\":\"00:00:30\",\"probeMinC\":\"NotSupported\",\"probeMaxC\":\"NotSupported\",\"probeDefaultC\":\"NotSupported\",\"probeMinF\":\"NotSupported\",\"probeMaxF\":\"NotSupported\",\"probeDefaultF\":\"NotSupported\",\"powerDefault\":\"NotSupported\",\"powerListLength\":\"0\"},{\"mode\":\"AirFryer\",\"version\":\"0100\",\"default\":\"NotSupported\",\"control\":\"Setting\",\"cavity\":\"Single\",\"tempMinC\":\"40\",\"tempMaxC\":\"200\",\"tempDefaultC\":\"190\",\"tempListLengthC\":\"0\",\"tempMinF\":\"NotSupported\",\"tempMaxF\":\"NotSupported\",\"tempDefaultF\":\"NotSupported\",\"tempListLengthF\":\"0\",\"timeMin\":\"00:00:10\",\"timeMax\":\"01:00:00\",\"timeDefault\":\"00:00:30\",\"probeMinC\":\"NotSupported\",\"probeMaxC\":\"NotSupported\",\"probeDefaultC\":\"NotSupported\",\"probeMinF\":\"NotSupported\",\"probeMaxF\":\"NotSupported\",\"probeDefaultF\":\"NotSupported\",\"powerDefault\":\"NotSupported\",\"powerListLength\":\"0\"},{\"mode\":\"Grill\",\"version\":\"0100\",\"default\":\"NotSupported\",\"control\":\"Setting\",\"cavity\":\"Single\",\"tempMinC\":\"NotSupported\",\"tempMaxC\":\"NotSupported\",\"tempDefaultC\":\"NotSupported\",\"tempListLengthC\":\"0\",\"tempMinF\":\"NotSupported\",\"tempMaxF\":\"NotSupported\",\"tempDefaultF\":\"NotSupported\",\"tempListLengthF\":\"0\",\"timeMin\":\"00:00:10\",\"timeMax\":\"01:00:00\",\"timeDefault\":\"00:00:30\",\"probeMinC\":\"NotSupported\",\"probeMaxC\":\"NotSupported\",\"probeDefaultC\":\"NotSupported\",\"probeMinF\":\"NotSupported\",\"probeMaxF\":\"NotSupported\",\"probeDefaultF\":\"NotSupported\",\"powerDefault\":\"NotSupported\",\"powerListLength\":\"0\"},{\"mode\":\"Deodorization\",\"version\":\"0100\",\"default\":\"NotSupported\",\"control\":\"Setting\",\"cavity\":\"Single\",\"tempMinC\":\"NotSupported\",\"tempMaxC\":\"NotSupported\",\"tempDefaultC\":\"NotSupported\",\"tempListLengthC\":\"0\",\"tempMinF\":\"NotSupported\",\"tempMaxF\":\"NotSupported\",\"tempDefaultF\":\"NotSupported\",\"tempListLengthF\":\"0\",\"timeMin\":\"00:00:10\",\"timeMax\":\"00:15:00\",\"timeDefault\":\"00:05:00\",\"probeMinC\":\"NotSupported\",\"probeMaxC\":\"NotSupported\",\"probeDefaultC\":\"NotSupported\",\"probeMinF\":\"NotSupported\",\"probeMaxF\":\"NotSupported\",\"probeDefaultF\":\"NotSupported\",\"powerDefault\":\"NotSupported\",\"powerListLength\":\"0\"}]" + } + }, + { + "href": "/operational/state/vs/0", + "rep": { + "x.com.samsung.da.state": "Ready", + "x.com.samsung.da.operationTime": "00:00:00", + "x.com.samsung.da.remainingTime": "00:00:00", + "x.com.samsung.da.progressPercentage": "0" + } + }, + { + "href": "/otninformation/vs/0", + "rep": { + "x.com.samsung.da.target": "", + "x.com.samsung.da.newVersionAvailable": "false" + } + }, + { + "href": "/alarms/vs/0", + "rep": { + "rt": [ + "x.com.samsung.da.alarms" + ], + "if": [ + "oic.if.baseline", + "oic.if.s" + ], + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "Alarm", + "x.com.samsung.da.alarmType": "Device", + "x.com.samsung.da.code": "OV_E_OFF", + "x.com.samsung.da.triggeredTime": "2026-07-31T04:03:50" + } + ] + } + }, + { + "href": "/oven/vs/0", + "rep": { + "x.com.samsung.da.state": "Ready", + "x.com.samsung.da.recipe": "00000000000000", + "x.com.samsung.da.powerLevel": "0W" + } + }, + { + "href": "/power/vs/0", + "rep": { + "x.com.samsung.da.power": "Off" + } + }, + { + "href": "/recipe/cook/vs/0", + "rep": { + "x.com.samsung.da.recipeDisplay": "{\"language\":\"ko_KR\",\"menu\":\"\",\"servingSize\":\"\",\"option\":\"\"}" + } + }, + { + "href": "/temperatures/vs/0", + "rep": { + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "Temperature", + "x.com.samsung.da.desired": "0", + "x.com.samsung.da.current": "1", + "x.com.samsung.da.increment": "5", + "x.com.samsung.da.unit": "Celsius" + } + ] + } + } + ] +} diff --git a/tests/test_by_type.py b/tests/test_by_type.py index 7d5173f..05d24c0 100644 --- a/tests/test_by_type.py +++ b/tests/test_by_type.py @@ -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 diff --git a/tests/test_golden_regression.py b/tests/test_golden_regression.py index e0e2ffa..898c67f 100644 --- a/tests/test_golden_regression.py +++ b/tests/test_golden_regression.py @@ -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 diff --git a/tests/test_microwave_capabilities.py b/tests/test_microwave_capabilities.py index 7253609..20d4afa 100644 --- a/tests/test_microwave_capabilities.py +++ b/tests/test_microwave_capabilities.py @@ -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 # ---------------------------------------------------------------------------