From ede87196365fe219519f203328629420d194f8f4 Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Fri, 24 Jul 2026 15:26:21 +0000 Subject: [PATCH] Add Samsung microwave support (TP2X_DA-KS-MICROWAVE-01011-class, issue #66) Reuses the oven family's door/connected/operational-state capabilities wholesale (identical resource shapes) and adds microwave-specific mode/ cavity/temperature capabilities for the fields that differ (cook-mode vocabulary, powerLevel, no setpoint write). Resolves via a new '-MICROWAVE-' modelNum token fallback, mirroring the existing '-OVEN-'/'-RANGE-' rules. --- README.md | 5 +- .../localthings/registry/by_type/__init__.py | 8 +- .../localthings/registry/by_type/microwave.py | 24 ++ .../registry/capabilities/ignored.py | 7 + .../registry/capabilities/microwave.py | 132 +++++++++++ tests/fixtures/golden/microwave.json | 22 ++ tests/fixtures/microwave_device.json | 217 ++++++++++++++++++ tests/test_golden_regression.py | 17 ++ tests/test_microwave_capabilities.py | 115 ++++++++++ 9 files changed, 544 insertions(+), 3 deletions(-) create mode 100644 custom_components/localthings/registry/by_type/microwave.py create mode 100644 custom_components/localthings/registry/capabilities/microwave.py create mode 100644 tests/fixtures/golden/microwave.json create mode 100644 tests/fixtures/microwave_device.json create mode 100644 tests/test_microwave_capabilities.py diff --git a/README.md b/README.md index cc0e27f..8a80dbb 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ Your state stays on your LAN: HA talks to the appliance over a direct DTLS sessi | Air conditioner | `by_type/airconditioner.py` | | Air purifier | `by_type/air_purifier.py` | | Dryer | `by_type/dryer.py` | +| Microwave | `by_type/microwave.py` | | Oven | `by_type/oven.py` | | Cooktop (read-only burner status) | `by_type/cooktop.py` | | Range hood | `by_type/range_hood.py` | @@ -116,8 +117,8 @@ custom_components/localthings/ identity.py Reads device identity for type detection redact.py Strips account/identity data before diagnostics leave HA capabilities/ Shared + per-family Capability definitions (common, airconditioner, - cooktop, range_hood, dryer, oven, dishwasher, fridge, washer, - laundry, operational, ignored) + cooktop, range_hood, dryer, microwave, oven, dishwasher, fridge, + washer, laundry, operational, ignored) by_type/ One DeviceRegistry per appliance type, composed from capabilities/ tests/ Registry composition, discovery, entity descriptors, coordinator/observe behavior, and golden-file regression against captured device dumps diff --git a/custom_components/localthings/registry/by_type/__init__.py b/custom_components/localthings/registry/by_type/__init__.py index 7f6b864..cb7220f 100644 --- a/custom_components/localthings/registry/by_type/__init__.py +++ b/custom_components/localthings/registry/by_type/__init__.py @@ -3,7 +3,7 @@ from typing import Optional from ._base import DeviceRegistry from . import ( - air_purifier, airconditioner, cooktop, dishwasher, dryer, oven, + air_purifier, airconditioner, cooktop, dishwasher, dryer, microwave, oven, range as _range, range_hood, refrigerator, washer, ) @@ -21,6 +21,7 @@ _REGISTRY_BY_KEY: dict[str, DeviceRegistry] = { 'cooktop': cooktop.REGISTRY, 'dishwasher': dishwasher.REGISTRY, 'dryer': dryer.REGISTRY, + 'microwave': microwave.REGISTRY, 'oven': oven.REGISTRY, 'hood': range_hood.REGISTRY, 'range': _range.REGISTRY, @@ -141,6 +142,11 @@ def for_device_by_model(model_num: str, description: str) -> Optional[DeviceRegi # oneUiVersion and doesn't match the washer/dryer/dishwasher prefix map. if key is None and '-OVEN-' in (model_num or '').upper(): key = 'oven' + # Microwaves (e.g. TP2X_DA-KS-MICROWAVE-01011, issue #66) -- same + # board-family naming as the range/oven combos above; reports no + # oneUiVersion and doesn't match the washer/dryer/dishwasher prefix map. + if key is None and '-MICROWAVE-' in (model_num or '').upper(): + key = 'microwave' return _REGISTRY_BY_KEY.get(key) if key else None diff --git a/custom_components/localthings/registry/by_type/microwave.py b/custom_components/localthings/registry/by_type/microwave.py new file mode 100644 index 0000000..49b072f --- /dev/null +++ b/custom_components/localthings/registry/by_type/microwave.py @@ -0,0 +1,24 @@ +"""Microwave device registry — issue #66. + +Reuses the oven family's door/connected/operational-state capabilities +wholesale (byte-for-byte identical resource shapes, all generically-keyed) +and adds microwave-specific mode/cavity/temperature capabilities for the +fields that differ. See capabilities/microwave.py's module docstring. +""" +from ..capabilities import common, ignored, microwave, oven +from ._base import DeviceRegistry, _build + +REGISTRY = DeviceRegistry( + name='microwave', + capabilities=_build([ + *ignored.IGNORED, + *common.UNIVERSAL, + *common.POWER, + oven.OVEN_DOOR, + oven.OVEN_CONNECTED, + oven.OVEN_OPERATIONAL_STATE, + microwave.MICROWAVE_MODE, + microwave.MICROWAVE_CAVITY, + microwave.MICROWAVE_TEMPERATURE, + ]), +) diff --git a/custom_components/localthings/registry/capabilities/ignored.py b/custom_components/localthings/registry/capabilities/ignored.py index b7e3cc1..4efc5d4 100644 --- a/custom_components/localthings/registry/capabilities/ignored.py +++ b/custom_components/localthings/registry/capabilities/ignored.py @@ -131,4 +131,11 @@ IGNORED: list[Capability] = [ # parallel write-capable capability around an unverified generic OCF # write contract. Capability(href='/operational/state/0'), + + # Microwave (issue #66): JSON-encoded {language, menu, servingSize, + # option} display string for the active cook recipe/program. Every + # field is empty on the only dump seen so far (device idle, no active + # cook program) -- no confirmed shape to build an entity around. + # Revisit if a dump with an active cook program surfaces. + Capability(href='/recipe/cook/vs/0'), ] diff --git a/custom_components/localthings/registry/capabilities/microwave.py b/custom_components/localthings/registry/capabilities/microwave.py new file mode 100644 index 0000000..5256dda --- /dev/null +++ b/custom_components/localthings/registry/capabilities/microwave.py @@ -0,0 +1,132 @@ +"""Capabilities for the microwave family (Samsung MW5300A-class, issue #66). + +/doors/vs/0, /connected/vs/0, and /operational/state/vs/0 report the exact +same field shapes as the oven family's resources of the same name (all +generically-keyed: door_open, cloud_connected, machine_state, cycle_active, +progress_percentage, operation_time_minutes, finish_time, cook_time, stop), +so those capabilities are reused directly from oven.py rather than +re-declared here -- see by_type/microwave.py. + +/mode/vs/0's cook-mode vocabulary (MicroWave/MicroWaveGrill/Grill/Autocook) +and options-array toggles (only a Sound_On/Off slot on this dump -- no +UpperLamp/fastpreheat/NaturalSteam like the oven family) are microwave- +specific, so it gets its own capability here. /oven/vs/0 additionally +reports a powerLevel field the oven family's cavity capability doesn't +carry, so it also gets its own. + +Cycle start/pause is deliberately not modeled: unlike the oven family +(whose module docstring documents confirmed-unreliable local cycle-start +writes from live testing), no live microwave has exercised writes at all -- +this module is built from a single diagnostics dump. The shared +STOP_BUTTON's state='Ready' RMW is safe and reused via +oven.OVEN_OPERATIONAL_STATE. The /temperatures/vs/0 reading is exposed +read-only for the same reason: desired='0' on the only dump seen, well +below any plausible cook temperature, with no confirmed write contract. + +/recipe/cook/vs/0 (a JSON-encoded {language, menu, servingSize, option} +display string) is not modeled here -- every field is empty on this dump +(device idle, no active cook program); see ignored.py. +""" +from ..capability import Capability +from ..entities import SelectDesc, SensorDesc, SwitchDesc +from .common import normalize_temp_unit + + +def _int(v): + try: + return int(v) + except (TypeError, ValueError): + return None + + +def _cavity_temp_unit(rep): + items = rep.get('x.com.samsung.da.items') or [] + unit = items[0].get('x.com.samsung.da.unit') if items else None + return normalize_temp_unit(unit, default='°C') + + +def _option_value(options, prefix): + """Find `_` in an options array and return .""" + for o in (options or []): + if isinstance(o, str) and o.startswith(prefix + '_'): + return o.split('_', 1)[1] + return None + + +def _replace_in_options(options, prefix, new_value): + """Return a new options list with the `_*` slot replaced.""" + return [f"{prefix}_{new_value}" if o.startswith(prefix + '_') else o + for o in options] + + +def _sound_write(p, rep, href=None): + if p not in ('On', 'Off'): + return None + opts = list(rep.get('x.com.samsung.da.options') or []) + if not opts: + return None + return ['mode', 'vs', '0'], { + 'x.com.samsung.da.options': _replace_in_options(opts, 'Sound', p), + } + + +# Cook modes as reported by /mode/vs/0's supportedModes (issue #66 dump) -- +# no oven-style Bake/Broil vocabulary applies to a microwave. +_MICROWAVE_MODES = ( + 'NoOperation', + 'MicroWave', + 'MicroWaveGrill', + 'Grill', + 'Autocook', + 'AutocookCustom', +) + + +def _mode_write(p, rep, href=None): + if p not in _MICROWAVE_MODES: + return None + return ['mode', 'vs', '0'], {'x.com.samsung.da.modes': [p]} + + +MICROWAVE_MODE = Capability( + href='/mode/vs/0', + poll_tier='warm', + entities=( + SelectDesc(key='cook_mode', field='x.com.samsung.da.modes', + name='Cooking mode', icon='mdi:tune', + options=_MICROWAVE_MODES, + value_fn=lambda v: v[0] if v else None, + write_fn=_mode_write), + SwitchDesc(key='sound', field='x.com.samsung.da.options', + name='Sound', icon='mdi:volume-high', + entity_category='config', + value_fn=lambda opts: _option_value(opts, 'Sound') == 'On', + write_fn=_sound_write), + ), +) + +MICROWAVE_CAVITY = Capability( + href='/oven/vs/0', + poll_tier='hot', + entities=( + SensorDesc(key='cavity_state', field='x.com.samsung.da.state', + name='Cavity state'), + # e.g. "0W"/"700W" -- unit is embedded in the string on every dump + # seen so far; shown as reported rather than parsed apart. + SensorDesc(key='power_level', field='x.com.samsung.da.powerLevel', + name='Power level', icon='mdi:flash'), + ), +) + +MICROWAVE_TEMPERATURE = Capability( + href='/temperatures/vs/0', + poll_tier='warm', + entities=( + SensorDesc(key='cavity_temp', field='x.com.samsung.da.items', + name='Cavity temperature', device_class='temperature', + state_class='measurement', entity_category='diagnostic', + unit_fn=_cavity_temp_unit, + value_fn=lambda items: _int( + (items[0].get('x.com.samsung.da.current') if items else None))), + ), +) diff --git a/tests/fixtures/golden/microwave.json b/tests/fixtures/golden/microwave.json new file mode 100644 index 0000000..1e9ce05 --- /dev/null +++ b/tests/fixtures/golden/microwave.json @@ -0,0 +1,22 @@ +{ + "state_keys": [ + "alarm_code", + "cavity_state", + "cavity_temp", + "child_lock", + "cloud_connected", + "cook_mode", + "cook_time", + "cycle_active", + "door_open", + "energy_kwh", + "finish_time", + "firmware_update", + "machine_state", + "operation_time_minutes", + "power_level", + "power_switch", + "progress_percentage", + "sound" + ] +} diff --git a/tests/fixtures/microwave_device.json b/tests/fixtures/microwave_device.json new file mode 100644 index 0000000..dfbb1cc --- /dev/null +++ b/tests/fixtures/microwave_device.json @@ -0,0 +1,217 @@ +{ + "meta": { + "model": "TP2X_DA-KS-MICROWAVE-01011 (MW5300A-/KO0)", + "device_type": "microwave", + "source": "issue #66 diagnostics (scrubbed)", + "note": "Microwave reports no oneUiVersion; resolved via the '-MICROWAVE-' modelNum token fallback in for_device_by_model, mirroring the '-OVEN-'/'-RANGE-' fallbacks added for issues #55/#44." + }, + "device0": [ + { + "rt": [ + "x.com.samsung.devcol", + "oic.wk.col" + ], + "if": [ + "oic.if.baseline", + "oic.if.ll", + "oic.if.b" + ] + }, + { + "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": "Close" + } + ], + "rt": [ + "x.com.samsung.da.doors" + ], + "if": [ + "oic.if.baseline", + "oic.if.s" + ] + } + }, + { + "href": "/energy/consumption/vs/0", + "rep": { + "x.com.samsung.da.instantaneousPower": "-500", + "x.com.samsung.da.instantaneousPowerUnit": "W", + "x.com.samsung.da.cumulativePower": "12500", + "x.com.samsung.da.cumulativeUnit": "Wh" + } + }, + { + "href": "/information/vs/0", + "rep": { + "x.com.samsung.da.modelNum": "TP2X_DA-KS-MICROWAVE-01011|40444841|50040100021511000200000000000000", + "x.com.samsung.da.description": "MW5300A-/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-04448A_21010500", + "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": "622", + "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": [ + "Autocook", + "NoOperation", + "MicroWave", + "MicroWaveGrill", + "Grill", + "AutocookCustom" + ], + "x.com.samsung.da.modes": [ + "NoOperation" + ], + "x.com.samsung.da.options": [ + "DeviceType_MW5300A-/KO0", + "ScreenTimeOut_10min", + "Sound_On" + ], + "rt": [ + "x.com.samsung.da.mode" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ] + } + }, + { + "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", + "rt": [ + "x.com.samsung.da.operation" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ] + } + }, + { + "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-24T13:38:49" + } + ] + } + }, + { + "href": "/oven/vs/0", + "rep": { + "x.com.samsung.da.state": "Ready", + "x.com.samsung.da.recipe": "00000000000000", + "x.com.samsung.da.powerLevel": "0W", + "rt": [ + "x.com.samsung.da.oven" + ], + "if": [ + "oic.if.baseline", + "oic.if.s" + ] + } + }, + { + "href": "/power/vs/0", + "rep": { + "x.com.samsung.da.power": "Off" + } + }, + { + "href": "/recipe/cook/vs/0", + "rep": { + "x.com.samsung.da.recipeDisplay": "{\"language\":\"None\",\"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" + } + ], + "rt": [ + "x.com.samsung.da.temperatures" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ] + } + } + ] +} diff --git a/tests/test_golden_regression.py b/tests/test_golden_regression.py index 56ba878..a9b0a64 100644 --- a/tests/test_golden_regression.py +++ b/tests/test_golden_regression.py @@ -294,6 +294,23 @@ def test_registry_reproduces_golden_state_keys_for_oven(): ) +def test_registry_reproduces_golden_state_keys_for_microwave(): + """Microwave (model TP2X_DA-KS-MICROWAVE-01011, issue #66) -- reports no + oneUiVersion; resolved via the '-MICROWAVE-' modelNum token fallback in + for_device_by_model, mirroring the '-RANGE-'/'-OVEN-' fallbacks. Reuses + the oven family's door/connected/operational-state capabilities and adds + microwave-specific mode/cavity/temperature capabilities.""" + from tests.conftest import _load_device + resources = _load_device('microwave') + golden = json.loads((GOLDEN / 'microwave.json').read_text()) + state_keys = _new_state_keys('microwave', 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_resources_from_batch_preferred_over_flat(): from tests.conftest import _resources_from_dump dump = { diff --git a/tests/test_microwave_capabilities.py b/tests/test_microwave_capabilities.py new file mode 100644 index 0000000..573cb41 --- /dev/null +++ b/tests/test_microwave_capabilities.py @@ -0,0 +1,115 @@ +"""Unit tests for microwave-family capabilities (issue #66).""" +from custom_components.localthings.registry.by_type import for_device_by_model +from custom_components.localthings.registry.capabilities import microwave +from custom_components.localthings.registry.discovery import discover + + +# --------------------------------------------------------------------------- +# Device-type detection + full-dump coverage +# --------------------------------------------------------------------------- + +def test_microwave_fixture_resolves_and_has_no_unbound_hrefs(): + """The issue #66 dump previously came back device_type='unknown' with + /connected/vs/0 and /recipe/cook/vs/0 unbound -- resolving via the + '-MICROWAVE-' modelNum token fallback must leave every href in the + microwave registry bound or ignored.""" + from tests.conftest import _load_device + resources = _load_device('microwave') + info = resources['/information/vs/0'] + reg = for_device_by_model( + info['x.com.samsung.da.modelNum'], info['x.com.samsung.da.description']) + assert reg is not None + assert reg.name == 'microwave' + + unbound = [] + discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append) + assert unbound == [] + + +# --------------------------------------------------------------------------- +# MICROWAVE_MODE -- SelectDesc with non-empty options +# --------------------------------------------------------------------------- + +def test_microwave_mode_options_nonempty(): + assert len(microwave.MICROWAVE_MODE.entities[0].options) > 0 + + +def test_microwave_mode_write_round_trips(): + desc = microwave.MICROWAVE_MODE.entities[0] + valid_mode = desc.options[1] # e.g. 'MicroWave' + path, body = desc.write_fn(valid_mode, {}) + assert path == ['mode', 'vs', '0'] + assert body['x.com.samsung.da.modes'] == [valid_mode] + + +def test_microwave_mode_rejects_unknown(): + desc = microwave.MICROWAVE_MODE.entities[0] + assert desc.write_fn('SpaghettiMode', {}) is None + + +# --------------------------------------------------------------------------- +# MICROWAVE_MODE -- sound options-array RMW +# --------------------------------------------------------------------------- + +def _mode_rep(*extra_opts): + return {'x.com.samsung.da.options': [ + 'DeviceType_MW5300A-/KO0', 'ScreenTimeOut_10min', 'Sound_On', *extra_opts, + ]} + + +def test_sound_write_preserves_other_options(): + desc = next(e for e in microwave.MICROWAVE_MODE.entities if e.key == 'sound') + path, body = desc.write_fn('Off', _mode_rep()) + opts = body['x.com.samsung.da.options'] + assert 'Sound_Off' in opts + assert 'ScreenTimeOut_10min' in opts # other slot unchanged + + +def test_sound_write_requires_existing_options(): + desc = next(e for e in microwave.MICROWAVE_MODE.entities if e.key == 'sound') + assert desc.write_fn('On', {}) is None + + +def test_sound_write_rejects_invalid_payload(): + desc = next(e for e in microwave.MICROWAVE_MODE.entities if e.key == 'sound') + assert desc.write_fn('Loud', _mode_rep()) is None + + +# --------------------------------------------------------------------------- +# MICROWAVE_CAVITY -- plain sensors, no write path +# --------------------------------------------------------------------------- + +def test_cavity_state_reads_state_field(): + desc = next(e for e in microwave.MICROWAVE_CAVITY.entities if e.key == 'cavity_state') + assert desc.value_fn('Ready') == 'Ready' + + +def test_power_level_reads_raw_string(): + desc = next(e for e in microwave.MICROWAVE_CAVITY.entities if e.key == 'power_level') + assert desc.value_fn('700W') == '700W' + + +# --------------------------------------------------------------------------- +# MICROWAVE_TEMPERATURE -- read-only current-temperature sensor +# --------------------------------------------------------------------------- + +def test_cavity_temp_reads_current_field(): + desc = microwave.MICROWAVE_TEMPERATURE.entities[0] + items = [{'x.com.samsung.da.current': '1', 'x.com.samsung.da.desired': '0'}] + assert desc.value_fn(items) == 1 + + +def test_cavity_temp_missing_items_is_none(): + desc = microwave.MICROWAVE_TEMPERATURE.entities[0] + assert desc.value_fn([]) is None + + +def test_cavity_temp_unit_defaults_celsius(): + from custom_components.localthings.registry.capabilities.microwave import _cavity_temp_unit + rep = {'x.com.samsung.da.items': [{'x.com.samsung.da.unit': 'Celsius'}]} + assert _cavity_temp_unit(rep) == '°C' + + +def test_cavity_temp_unit_falls_back_when_absent(): + from custom_components.localthings.registry.capabilities.microwave import _cavity_temp_unit + assert _cavity_temp_unit({'x.com.samsung.da.items': []}) == '°C'