diff --git a/README.md b/README.md index e9881b3..adb44db 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,8 @@ Your state stays on your LAN: HA talks to the appliance over a direct DTLS sessi | Air purifier | `by_type/air_purifier.py` | | Dehumidifier | `by_type/dehumidifier.py` | | Dryer | `by_type/dryer.py` | -| Oven (including combi microwaves) | `by_type/oven.py` | +| Oven | `by_type/oven.py` | +| Microwave (combi and plain) | `by_type/microwave.py` | | Gas cooktop (read-only burner status) | `by_type/cooktop.py` | | Range hood | `by_type/range_hood.py` | | Range | `by_type/range.py` | diff --git a/custom_components/localthings/registry/by_type/__init__.py b/custom_components/localthings/registry/by_type/__init__.py index 13107d8..4803a26 100644 --- a/custom_components/localthings/registry/by_type/__init__.py +++ b/custom_components/localthings/registry/by_type/__init__.py @@ -4,8 +4,8 @@ from typing import Optional from ._base import DeviceRegistry from . import ( air_purifier, airconditioner, cooktop, dehumidifier, dishwasher, dryer, - induction_cooktop, oven, range as _range, range_hood, refrigerator, - vacuum_station, washer, water_purifier, + induction_cooktop, microwave, oven, range as _range, range_hood, + refrigerator, vacuum_station, washer, water_purifier, ) __all__ = [ @@ -24,6 +24,7 @@ _REGISTRY_BY_KEY: dict[str, DeviceRegistry] = { 'dishwasher': dishwasher.REGISTRY, 'dryer': dryer.REGISTRY, 'induction_cooktop': induction_cooktop.REGISTRY, + 'microwave': microwave.REGISTRY, 'oven': oven.REGISTRY, 'hood': range_hood.REGISTRY, 'range': _range.REGISTRY, @@ -232,15 +233,18 @@ 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' - # Combi microwaves (e.g. TP1X_DA-KS-MICROWAVE-01041, issue #121) -- same - # board family as the wall oven above (an '/oven/vs/0' cavity resource - # with Convection/AirFryer/Grill/MicroWave modes on /mode/vs/0), just a - # different cavity. Reports no oneUiVersion and doesn't match the - # washer/dryer/dishwasher prefix map either. Binds cleanly against the - # existing oven registry once routed here (confirmed against the issue - # #121 dump) -- no microwave-specific device type needed. + # Microwaves, both combi (e.g. TP1X_DA-KS-MICROWAVE-01041, issue #121) + # and plain (e.g. TP2X_DA-KS-MICROWAVE-01011, issue #66) -- same board + # family as the wall oven above (an '/oven/vs/0' cavity resource, same + # /operational/state/vs/0 + /doors/vs/0 shape), but a distinct mode + # vocabulary (Convection/AirFryer/Grill/MicroWave*) and setpoint bounds + # from the oven registry, plus a powerLevel field ovens don't report -- + # its own device type rather than folded into 'oven' (issue #121 shipped + # it onto the oven registry initially; split out per user feedback). + # Reports no oneUiVersion and doesn't match the washer/dryer/dishwasher + # prefix map either. if key is None and '-MICROWAVE-' in (model_num or '').upper(): - key = 'oven' + key = 'microwave' # Standalone induction cooktops (e.g. TP1X_DA-KS-COOKTOP-01011, issue # #86) -- same board family and '/cooktop/status/vs/0' resource shape # as the range combo above, but no oven attached at all. Distinct from 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..c038e49 --- /dev/null +++ b/custom_components/localthings/registry/by_type/microwave.py @@ -0,0 +1,28 @@ +"""Microwave device registry (combi and plain microwaves, issues #66/#121). + +Shares the oven board family's cavity/cook-cycle resource shape, so the +operational-state, door, cloud-connected, and quick-recipe-display +Capability objects are reused directly from oven.py rather than duplicated. +Cooking mode, setpoint, cavity power level, and lamp are genuinely +different for this family (different mode vocabulary, different setpoint +bounds, an extra powerLevel field, a differently-named lamp option) and are +defined fresh in capabilities/microwave.py -- see that module's 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, + microwave.MICROWAVE_CAVITY, + microwave.MICROWAVE_SETPOINT, + microwave.MICROWAVE_MODE, + oven.OVEN_OPERATIONAL_STATE, + oven.OVEN_DOOR, + oven.OVEN_CONNECTED, + oven.OVEN_RECIPE_COOK, + ]), +) diff --git a/custom_components/localthings/registry/capabilities/microwave.py b/custom_components/localthings/registry/capabilities/microwave.py new file mode 100644 index 0000000..7dcb7f5 --- /dev/null +++ b/custom_components/localthings/registry/capabilities/microwave.py @@ -0,0 +1,195 @@ +"""Capabilities for the Samsung microwave family (TP1X_DA-KS-MICROWAVE-* +class boards, both combi units and plain microwaves). + +Shares the oven board family's cavity/cook-cycle resource shape +(`/operational/state/vs/0`, `/doors/vs/0`, `/connected/vs/0`, +`/recipe/cook/vs/0`) -- those Capability objects are reused directly from +oven.py in by_type/microwave.py rather than duplicated. What's genuinely +different from an oven, and defined fresh here: + + * Cooking-mode vocabulary: MicroWave/MicroWaveGrill/MicroWaveConvection/ + KeepWarm never appear on an oven's /mode/vs/0, and this family spells + some shared-sounding modes differently than oven.py's own constants + (e.g. 'AirFryer', not oven.py's 'AirFry') -- a distinct SelectDesc and + mode list, not oven.OVEN_MODE. + * Setpoint bounds: this family's Convection/MicroWaveConvection modeSpec + (issue #121's MW7300B dump) reports 40-200 C / step 5, not oven.py's + 30-270 C range (verified against a different, bake-oven-class board). + * Cavity: /oven/vs/0 here also carries a `powerLevel` field (100W-900W + on the MicroWave mode's powerListData) that plain ovens don't report -- + exposed as its own sensor. + * Lamp: this family's option-array token is bare 'Lamp' (issue #137's + 'Lamp_Off'), not oven.py's 'UpperLamp' -- and it's genuinely absent on + the combi dump (issue #121), so it's gated with exists_fn rather than + assumed universal like oven.py's lamp switch. + +Note: cooking-mode writes are unproven here, same caveat as oven.py's +OVEN_MODE -- exposed as a SelectDesc for fidelity, first real-world write +is also the test. +""" +from ..capability import Capability +from ..entities import NumberDesc, SelectDesc, SensorDesc, SwitchDesc +from .common import int_or_none, normalize_temp_unit +from .laundry import option_value, option_write + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +# Union of every mode seen across the two known dumps: issue #121's combi +# MW7300B (NoOperation/Autocook/AutocookCustom/Convection/AirFryer/Grill/ +# MicroWave/MicroWaveGrill/MicroWaveConvection/Deodorization) and issue #137's +# plain ME7500D (NoOperation/MicroWave/Autocook/KeepWarm). No dump has shown +# every mode below on one device -- the select surfaces whatever a given +# board's own /mode/vs/0 supportedModes reports; an entry here that a device +# never sends just never gets picked. +_MICROWAVE_MODES = ( + 'NoOperation', + 'MicroWave', + 'MicroWaveGrill', + 'MicroWaveConvection', + 'Convection', + 'AirFryer', + 'Grill', + 'Autocook', + 'AutocookCustom', + 'Deodorization', + 'KeepWarm', +) + +# Convection/MicroWaveConvection modeSpec on issue #121's dump: tempMinC 40, +# tempMaxC 200, tempIntervalC 5. No Fahrenheit dump exists for this family; +# unlike oven.py's own SETPOINT_MIN_F/MAX_F/STEP_F (independently verified +# against issue #44's range dump), there's nothing to verify a microwave's +# Fahrenheit bounds against, so this module only exposes the setpoint +# control when the live unit is Celsius (see _microwave_temp_unit below). +SETPOINT_MIN_C = 40 +SETPOINT_MAX_C = 200 +SETPOINT_STEP_C = 5 + + +def _microwave_temp_unit(rep): + """Same shape as oven.py's _oven_temp_unit: /temperatures/vs/0 items[] + carries a per-item x.com.samsung.da.unit field. Both known dumps for + this family report 'Celsius'; kept live rather than hardcoded per the + fridge/oven convention (issue #7).""" + 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 _setpoint_write(p, rep, href=None): + """RMW write to /temperatures/vs/0 items array -- unproven for this + family (no live write confirmed against a real unit), same "exposed for + fidelity" caveat as the mode select.""" + try: + temp = float(p) + except (TypeError, ValueError): + return None + temp_i = int(round(temp / SETPOINT_STEP_C) * SETPOINT_STEP_C) + if not (SETPOINT_MIN_C <= temp_i <= SETPOINT_MAX_C): + return None + items = rep.get('x.com.samsung.da.items') + if not items: + return None + items = [dict(it) for it in items] + items[0]['x.com.samsung.da.desired'] = str(temp_i) + return ['temperatures', 'vs', '0'], {'x.com.samsung.da.items': items} + + +def _power_level_watts(v): + """'100W'..'900W' (issue #121) or a bare '0' (issue #137) -> int watts.""" + if v is None: + return None + s = str(v).strip() + if s.upper().endswith('W'): + s = s[:-1] + return int_or_none(s) + + +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]} + + +def _sound_write(p, rep, href=None): + if p not in ('On', 'Off'): + return None + if not rep.get('x.com.samsung.da.options'): + return None + return ['mode', 'vs', '0'], { + 'x.com.samsung.da.options': option_write('Sound', p), + } + + +def _lamp_exists(rep, resources): + return option_value(rep.get('x.com.samsung.da.options'), 'Lamp') is not None + + +def _lamp_write(p, rep, href=None): + if p not in ('On', 'Off'): + return None + if not rep.get('x.com.samsung.da.options'): + return None + return ['mode', 'vs', '0'], { + 'x.com.samsung.da.options': option_write('Lamp', p), + } + + +# --------------------------------------------------------------------------- +# Capabilities +# --------------------------------------------------------------------------- + +MICROWAVE_CAVITY = Capability( + href='/oven/vs/0', + poll_tier='hot', + entities=( + SensorDesc(key='cavity_state', field='x.com.samsung.da.state'), + SensorDesc(key='power_level', field='x.com.samsung.da.powerLevel', + unit='W', value_fn=_power_level_watts), + ), +) + +MICROWAVE_SETPOINT = Capability( + href='/temperatures/vs/0', + poll_tier='hot', + entities=( + NumberDesc(key='setpoint', field='x.com.samsung.da.items', + device_class='temperature', unit_fn=_microwave_temp_unit, + native_min=float(SETPOINT_MIN_C), native_max=float(SETPOINT_MAX_C), + step=float(SETPOINT_STEP_C), icon='mdi:thermometer-chevron-up', + exists_fn=lambda rep, resources: _microwave_temp_unit(rep) == '°C', + value_fn=lambda items: int_or_none( + (items[0].get('x.com.samsung.da.desired') if items else None)), + write_fn=_setpoint_write), + SensorDesc(key='current_temp_c', field='x.com.samsung.da.items', + device_class='temperature', + state_class='measurement', unit_fn=_microwave_temp_unit, + value_fn=lambda items: int_or_none( + (items[0].get('x.com.samsung.da.current') if items else None))), + ), +) + +MICROWAVE_MODE = Capability( + href='/mode/vs/0', + poll_tier='warm', + entities=( + # SelectDesc first — test_microwave_mode_options_nonempty uses entities[0] + SelectDesc(key='cooking_mode', field='x.com.samsung.da.modes', + 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', + icon='mdi:volume-high', + entity_category='config', + value_fn=lambda opts: option_value(opts, 'Sound') == 'On', + write_fn=_sound_write), + SwitchDesc(key='lamp', field='x.com.samsung.da.options', + icon='mdi:track-light', + exists_fn=_lamp_exists, + value_fn=lambda opts: option_value(opts, 'Lamp') == 'On', + write_fn=_lamp_write), + ), +) diff --git a/custom_components/localthings/translations/en.json b/custom_components/localthings/translations/en.json index 2b5c321..dc92b91 100644 --- a/custom_components/localthings/translations/en.json +++ b/custom_components/localthings/translations/en.json @@ -165,6 +165,9 @@ "oven_setpoint": { "name": "Setpoint" }, + "setpoint": { + "name": "Setpoint" + }, "sound_volume": { "name": "Sound volume" }, @@ -344,6 +347,22 @@ "high": "High" } }, + "cooking_mode": { + "name": "Cooking mode", + "state": { + "no_operation": "No operation", + "micro_wave": "Microwave", + "micro_wave_grill": "Microwave + grill", + "micro_wave_convection": "Microwave + convection", + "convection": "Convection", + "air_fryer": "Air fry", + "grill": "Grill", + "autocook": "Auto cook", + "autocook_custom": "Auto cook (custom)", + "deodorization": "Deodorize", + "keep_warm": "Keep warm" + } + }, "oven_mode": { "name": "Cooking mode", "state": { @@ -704,6 +723,12 @@ "oven_state": { "name": "Cavity state" }, + "cavity_state": { + "name": "Cavity state" + }, + "power_level": { + "name": "Power level" + }, "paired_hood_fan_speed": { "name": "Paired hood fan speed" }, diff --git a/custom_components/localthings/translations/nl.json b/custom_components/localthings/translations/nl.json index 9699b1f..b23b93f 100644 --- a/custom_components/localthings/translations/nl.json +++ b/custom_components/localthings/translations/nl.json @@ -165,6 +165,9 @@ "oven_setpoint": { "name": "Instelpunt" }, + "setpoint": { + "name": "Instelpunt" + }, "sound_volume": { "name": "Geluidsvolume" }, @@ -344,6 +347,22 @@ "high": "Hoog" } }, + "cooking_mode": { + "name": "Bereidingsmodus", + "state": { + "no_operation": "Niet actief", + "micro_wave": "Magnetron", + "micro_wave_grill": "Magnetron + grill", + "micro_wave_convection": "Magnetron + hetelucht", + "convection": "Hetelucht", + "air_fryer": "Airfryen", + "grill": "Grillen", + "autocook": "Automatisch koken", + "autocook_custom": "Automatisch koken (aangepast)", + "deodorization": "Geurverwijdering", + "keep_warm": "Warmhouden" + } + }, "oven_mode": { "name": "Bereidingsmodus", "state": { @@ -704,6 +723,12 @@ "oven_state": { "name": "Status ovenruimte" }, + "cavity_state": { + "name": "Status ovenruimte" + }, + "power_level": { + "name": "Vermogensniveau" + }, "paired_hood_fan_speed": { "name": "Ventilatorsnelheid gekoppelde afzuigkap" }, diff --git a/tests/fixtures/golden/oven_mw7300b.json b/tests/fixtures/golden/microwave_mw7300b.json similarity index 75% rename from tests/fixtures/golden/oven_mw7300b.json rename to tests/fixtures/golden/microwave_mw7300b.json index 435dec4..f648bde 100644 --- a/tests/fixtures/golden/oven_mw7300b.json +++ b/tests/fixtures/golden/microwave_mw7300b.json @@ -1,25 +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", - "fast_preheat", "finish_time", "firmware_update", - "lamp", "machine_state", - "natural_steam", "operation_time_minutes", - "oven_mode", - "oven_setpoint", - "oven_state", + "power_level", "progress_percentage", "remote_control", + "setpoint", "sound" ] -} \ No newline at end of file +} diff --git a/tests/fixtures/oven_mw7300b_device.json b/tests/fixtures/microwave_mw7300b_device.json similarity index 100% rename from tests/fixtures/oven_mw7300b_device.json rename to tests/fixtures/microwave_mw7300b_device.json diff --git a/tests/test_by_type.py b/tests/test_by_type.py index 15ac1e2..b7a476e 100644 --- a/tests/test_by_type.py +++ b/tests/test_by_type.py @@ -329,19 +329,19 @@ class TestForDeviceByModel: assert reg is not None assert reg.name == 'airconditioner' - def test_oven_via_microwave_token(self): - """Issue #121: a combi microwave (MW7300B-/EU1) reports no + def test_microwave_via_microwave_token(self): + """Issue #121/#66: a combi microwave (MW7300B-/EU1) reports no oneUiVersion and an unrecognized consumer token; falls back to the - '-MICROWAVE-' token in modelNum onto the *existing* oven registry - (same '/oven/vs/0' cavity + cook-mode shape), not a new device - type.""" + '-MICROWAVE-' token in modelNum onto its own microwave registry + (initially folded into 'oven' for issue #121, split out into a + distinct device type per user feedback).""" from custom_components.localthings.registry.by_type import for_device_by_model reg = for_device_by_model( 'TP1X_DA-KS-MICROWAVE-01041|40475341|50040100021811000A00000000000000', 'MW7300B-/EU1', ) assert reg is not None - assert reg.name == 'oven' + assert reg.name == 'microwave' def test_vacuum_station_via_vskr_token(self): """Issue #131: a stick-vacuum clean/auto-empty station diff --git a/tests/test_golden_regression.py b/tests/test_golden_regression.py index 96f7c32..c13b45c 100644 --- a/tests/test_golden_regression.py +++ b/tests/test_golden_regression.py @@ -568,19 +568,20 @@ def test_registry_reproduces_golden_state_keys_for_airconditioner_windfree_oscil ) -def test_registry_reproduces_golden_state_keys_for_oven_mw7300b(): +def test_registry_reproduces_golden_state_keys_for_microwave_mw7300b(): """TP1X_DA-KS-MICROWAVE-01041 combi microwave (model MW7300B, issue #121) -- reports no oneUiVersion; resolved via the '-MICROWAVE-' - modelNum token fallback onto the *existing* oven registry rather than - a new device type, since it shares the same '/oven/vs/0' cavity and - '/mode/vs/0' cook-mode shape (Convection/AirFryer/Grill/MicroWave*). - The only href the oven registry didn't already cover was - /recipe/cook/vs/0 (oven.OVEN_RECIPE_COOK, an empty quick-recipe-display - blob with no entity).""" + modelNum token fallback onto its own microwave registry. Shares the + oven board family's operational-state/door/connected/recipe-cook + Capability objects (reused directly from oven.py), but has its own + cooking-mode vocabulary, setpoint bounds, and cavity power-level sensor + (capabilities/microwave.py) -- this device's initial routing folded it + into the oven registry (issue #121); split into its own device type per + user feedback that microwaves shouldn't show up as ovens.""" from tests.conftest import _load_device - resources = _load_device('oven_mw7300b') - golden = json.loads((GOLDEN / 'oven_mw7300b.json').read_text()) - state_keys = _new_state_keys('oven_mw7300b', resources) + resources = _load_device('microwave_mw7300b') + golden = json.loads((GOLDEN / 'microwave_mw7300b.json').read_text()) + state_keys = _new_state_keys('microwave_mw7300b', 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" diff --git a/tests/test_microwave_capabilities.py b/tests/test_microwave_capabilities.py new file mode 100644 index 0000000..45a7487 --- /dev/null +++ b/tests/test_microwave_capabilities.py @@ -0,0 +1,161 @@ +"""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.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(): + from tests.conftest import _load_device + resources = _load_device('microwave_mw7300b') + 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_SETPOINT — NumberDesc with RMW write semantics +# --------------------------------------------------------------------------- + +def test_microwave_setpoint_write_is_read_modify_write(): + desc = microwave.MICROWAVE_SETPOINT.entities[0] + rep = {'x.com.samsung.da.items': [{'x.com.samsung.da.desired': '0'}]} + path, body = desc.write_fn(180, rep) + assert path == ['temperatures', 'vs', '0'] + assert body['x.com.samsung.da.items'][0]['x.com.samsung.da.desired'] == '180' + + +def test_microwave_setpoint_rmw_preserves_other_item_fields(): + desc = microwave.MICROWAVE_SETPOINT.entities[0] + rep = {'x.com.samsung.da.items': [{ + 'x.com.samsung.da.current': '150', + 'x.com.samsung.da.desired': '150', + }]} + path, body = desc.write_fn(180, rep) + item = body['x.com.samsung.da.items'][0] + assert item['x.com.samsung.da.desired'] == '180' + assert item['x.com.samsung.da.current'] == '150' + + +def test_microwave_setpoint_clamps_to_step(): + desc = microwave.MICROWAVE_SETPOINT.entities[0] + rep = {'x.com.samsung.da.items': [{'x.com.samsung.da.desired': '0'}]} + _, body = desc.write_fn(182, rep) # nearest 5 = 180 + assert body['x.com.samsung.da.items'][0]['x.com.samsung.da.desired'] == '180' + + +def test_microwave_setpoint_rejects_out_of_range(): + desc = microwave.MICROWAVE_SETPOINT.entities[0] + rep = {'x.com.samsung.da.items': [{'x.com.samsung.da.desired': '100'}]} + assert desc.write_fn(20, rep) is None # below min (40) + assert desc.write_fn(210, rep) is None # above max (200) + + +def test_microwave_setpoint_rejects_missing_items(): + desc = microwave.MICROWAVE_SETPOINT.entities[0] + assert desc.write_fn(180, {}) is None + + +def test_microwave_setpoint_exists_only_for_celsius(): + """No Fahrenheit dump exists for this family (unlike oven.py's, verified + against issue #44) -- the writable setpoint stays hidden rather than + showing unverified bounds under the wrong unit.""" + desc = microwave.MICROWAVE_SETPOINT.entities[0] + celsius_rep = {'x.com.samsung.da.items': [{'x.com.samsung.da.unit': 'Celsius'}]} + fahrenheit_rep = {'x.com.samsung.da.items': [{'x.com.samsung.da.unit': 'Fahrenheit'}]} + assert desc.exists_fn(celsius_rep, {}) is True + assert desc.exists_fn(fahrenheit_rep, {}) is False + + +# --------------------------------------------------------------------------- +# MICROWAVE_CAVITY — power_level sensor +# --------------------------------------------------------------------------- + +def test_power_level_parses_watt_suffix(): + """Issue #121's combi dump reports e.g. '0W'.""" + desc = next(e for e in microwave.MICROWAVE_CAVITY.entities if e.key == 'power_level') + assert desc.value_fn('900W') == 900 + + +def test_power_level_parses_bare_number(): + """Issue #137's plain microwave reports the bare number, no 'W' suffix.""" + desc = next(e for e in microwave.MICROWAVE_CAVITY.entities if e.key == 'power_level') + assert desc.value_fn('0') == 0 + + +def test_power_level_handles_missing_value(): + desc = next(e for e in microwave.MICROWAVE_CAVITY.entities if e.key == 'power_level') + assert desc.value_fn(None) is None + + +# --------------------------------------------------------------------------- +# MICROWAVE_MODE — SelectDesc with non-empty, family-specific options +# --------------------------------------------------------------------------- + +def test_microwave_mode_options_nonempty(): + desc = microwave.MICROWAVE_MODE.entities[0] + assert len(desc.options) > 0 + assert 'MicroWave' in desc.options + assert 'AirFryer' in desc.options # distinct spelling from oven.py's 'AirFry' + + +def test_microwave_mode_write_round_trips(): + desc = microwave.MICROWAVE_MODE.entities[0] + path, body = desc.write_fn('MicroWave', {}) + assert path == ['mode', 'vs', '0'] + assert body['x.com.samsung.da.modes'] == ['MicroWave'] + + +def test_microwave_mode_rejects_unknown(): + desc = microwave.MICROWAVE_MODE.entities[0] + assert desc.write_fn('SpaghettiMode', {}) is None + + +# --------------------------------------------------------------------------- +# MICROWAVE_MODE — lamp/sound options-array writes +# --------------------------------------------------------------------------- + +def test_sound_write_is_single_token(): + desc = next(e for e in microwave.MICROWAVE_MODE.entities if e.key == 'sound') + rep = {'x.com.samsung.da.options': ['Sound_On']} + path, body = desc.write_fn('Off', rep) + assert path == ['mode', 'vs', '0'] + assert body == {'x.com.samsung.da.options': ['Sound_Off']} + + +def test_lamp_gated_absent_when_no_lamp_option(): + """Issue #121's combi dump has no 'Lamp_*' token at all -- unlike + oven.py's lamp switch (assumed universal), this one self-gates off.""" + desc = next(e for e in microwave.MICROWAVE_MODE.entities if e.key == 'lamp') + rep = {'x.com.samsung.da.options': ['DeviceType_MW7300B-/EU1', 'Sound_Off']} + assert desc.exists_fn(rep, {}) is False + + +def test_lamp_gated_present_when_lamp_option_reported(): + """Issue #137's plain microwave reports 'Lamp_Off'.""" + desc = next(e for e in microwave.MICROWAVE_MODE.entities if e.key == 'lamp') + rep = {'x.com.samsung.da.options': ['Lamp_Off', 'Sound_On']} + assert desc.exists_fn(rep, {}) is True + + +def test_lamp_write_is_single_token(): + desc = next(e for e in microwave.MICROWAVE_MODE.entities if e.key == 'lamp') + rep = {'x.com.samsung.da.options': ['Lamp_Off']} + path, body = desc.write_fn('On', rep) + assert path == ['mode', 'vs', '0'] + assert body == {'x.com.samsung.da.options': ['Lamp_On']} + + +def test_lamp_write_requires_existing_options(): + desc = next(e for e in microwave.MICROWAVE_MODE.entities if e.key == 'lamp') + assert desc.write_fn('On', {}) is None