diff --git a/custom_components/localthings/registry/capabilities/oven.py b/custom_components/localthings/registry/capabilities/oven.py index d866927..0a28cc9 100644 --- a/custom_components/localthings/registry/capabilities/oven.py +++ b/custom_components/localthings/registry/capabilities/oven.py @@ -52,14 +52,13 @@ SETPOINT_STEP_F = 5 # from Samsung documentation and firmware observations. The firmware will # reject unknown modes; missing entries here are a coverage gap, not a bug. # +# This is a fallback only, used when a device's own /mode/vs/0 doesn't report +# x.com.samsung.da.supportedModes at all -- see _oven_mode_options/ +# _oven_mode_write below. issue #138's range dump (NE63A6511SS/AA) reports # ConvectionRoast/KeepWarm/BreadProof/AirFryer/Dehydrate/SelfClean/SteamClean -# are confirmed against issue #138's range dump (NE63A6511SS/AA) -- its -# /mode/vs/0 supportedModes lists all of them, and the range family shares -# this select via range.py reusing oven.OVEN_MODE wholesale. 'AirFryer' is -# kept alongside 'AirFry' rather than replacing it -- the NV7000BS-class -# dump this list was originally inferred from spells it without the -# trailing 'er', and neither model's actual token is independently -# confirmed, so both are treated as real device-reported spellings. +# in its own supportedModes; those are read live rather than added here, per +# the adding-device-support skill's preference for device-reported option +# lists over hardcoded ones. _OVEN_MODES = ( 'NoOperation', 'Bake', @@ -67,17 +66,10 @@ _OVEN_MODES = ( 'Convection', 'ConvectionBake', 'ConvectionBroil', - 'ConvectionRoast', 'FrozenPizzaPlus', 'SlowCook', 'PlateWarm', 'AirFry', - 'AirFryer', - 'KeepWarm', - 'BreadProof', - 'Dehydrate', - 'SelfClean', - 'SteamClean', ) _SAMSUNG_STATE_TO_OCF = { @@ -189,8 +181,20 @@ def _cook_time_write(p, rep, href=None): } +def _oven_mode_options(resources): + """Live mode list from the device's own /mode/vs/0 supportedModes when + it reports one; the NV7000BS-era _OVEN_MODES guess otherwise. Mirrors + laundry.py's options_field pattern (buzzer_sound/finish_sound), but + needs the callable form rather than options_field because a static + fallback has to kick in when the device's own field is absent.""" + rep = resources.get('/mode/vs/0') or {} + live = rep.get('x.com.samsung.da.supportedModes') + return list(live) if live else list(_OVEN_MODES) + + def _oven_mode_write(p, rep, href=None): - if p not in _OVEN_MODES: + valid = rep.get('x.com.samsung.da.supportedModes') or _OVEN_MODES + if p not in valid: return None return ['mode', 'vs', '0'], {'x.com.samsung.da.modes': [p]} @@ -367,7 +371,7 @@ OVEN_MODE = Capability( # SelectDesc first — test_oven_mode_options_nonempty uses entities[0] SelectDesc(key='oven_mode', field='x.com.samsung.da.modes', icon='mdi:tune', - options=_OVEN_MODES, + options=_oven_mode_options, value_fn=lambda v: v[0] if v else None, write_fn=_oven_mode_write), SwitchDesc(key='lamp', field='x.com.samsung.da.options', diff --git a/tests/test_oven_capabilities.py b/tests/test_oven_capabilities.py index 22074df..926fd76 100644 --- a/tests/test_oven_capabilities.py +++ b/tests/test_oven_capabilities.py @@ -120,12 +120,29 @@ def test_oven_setpoint_native_bounds_track_live_unit(): # --------------------------------------------------------------------------- def test_oven_mode_options_nonempty(): - assert len(oven.OVEN_MODE.entities[0].options) > 0 + desc = oven.OVEN_MODE.entities[0] + assert callable(desc.options) + assert len(desc.options({})) > 0 + + +def test_oven_mode_options_falls_back_when_no_live_supported_modes(): + desc = oven.OVEN_MODE.entities[0] + assert desc.options({}) == list(oven._OVEN_MODES) + + +def test_oven_mode_options_reads_live_supported_modes(): + """issue #138: the device's own supportedModes list is used verbatim + when present, instead of the static _OVEN_MODES guess.""" + desc = oven.OVEN_MODE.entities[0] + resources = {'/mode/vs/0': { + 'x.com.samsung.da.supportedModes': ['Bake', 'AirFryer', 'SelfClean'], + }} + assert desc.options(resources) == ['Bake', 'AirFryer', 'SelfClean'] def test_oven_mode_write_round_trips(): desc = oven.OVEN_MODE.entities[0] - valid_mode = desc.options[1] # e.g. 'Bake' + valid_mode = desc.options({})[1] # e.g. 'Bake' path, body = desc.write_fn(valid_mode, {}) assert path == ['mode', 'vs', '0'] assert body['x.com.samsung.da.modes'] == [valid_mode] @@ -136,6 +153,19 @@ def test_oven_mode_rejects_unknown(): assert desc.write_fn('SpaghettiMode', {}) is None +def test_oven_mode_write_validates_against_live_supported_modes(): + """A device reporting its own supportedModes is validated against that + list, not the static fallback -- issue #138's AirFryer/SelfClean/etc. + are accepted, and a mode outside the device's own list is rejected even + if some other model's static guess would have allowed it.""" + desc = oven.OVEN_MODE.entities[0] + rep = {'x.com.samsung.da.supportedModes': ['Bake', 'AirFryer', 'SelfClean']} + path, body = desc.write_fn('AirFryer', rep) + assert path == ['mode', 'vs', '0'] + assert body['x.com.samsung.da.modes'] == ['AirFryer'] + assert desc.write_fn('FrozenPizzaPlus', rep) is None + + # --------------------------------------------------------------------------- # OVEN_MODE options-array writes (lamp, sound, fast_preheat, natural_steam). # Confirmed on real hardware (issue #54): a write only needs to carry the diff --git a/tests/test_range_ne63a6511_capabilities.py b/tests/test_range_ne63a6511_capabilities.py index 7b99006..c52da95 100644 --- a/tests/test_range_ne63a6511_capabilities.py +++ b/tests/test_range_ne63a6511_capabilities.py @@ -1,8 +1,9 @@ """Tests for the NE63A6511SS/AA range (issue #138) -- same no-/information/vs/0, no-burner-status shape as issue #74's NE63B8411SS, confirming the existing range_no_info detection path still resolves this model with zero unbound -hrefs, plus the ConvectionRoast/KeepWarm/BreadProof/AirFryer/Dehydrate/ -SelfClean/SteamClean modes this dump's supportedModes adds to oven.OVEN_MODE.""" +hrefs, plus oven.OVEN_MODE reading this dump's ConvectionRoast/KeepWarm/ +BreadProof/AirFryer/Dehydrate/SelfClean/SteamClean modes live from its own +/mode/vs/0 supportedModes rather than needing them hardcoded.""" from custom_components.localthings.registry.adapter import flatten from custom_components.localthings.registry.by_type import for_device_by_resources from custom_components.localthings.registry.capabilities import oven @@ -48,14 +49,20 @@ def test_expected_entities_present(): def test_oven_mode_accepts_this_devices_supported_modes(): """This dump's /mode/vs/0 supportedModes reports ConvectionRoast, KeepWarm, BreadProof, AirFryer, Dehydrate, SelfClean, and SteamClean -- - none of which were in oven._OVEN_MODES before issue #138, so writes to - them were silently rejected even though the device advertises them.""" + none of which were in the static oven._OVEN_MODES fallback, so a + hardcoded write-validation list would've silently rejected them even + though the device itself advertises them. write_fn reads supportedModes + off the live rep (the same one it's called with in production -- see + coordinator.py's async_send_command), so this passes without needing + those modes added to any Python list.""" + _, resources = _range() + live_rep = resources['/mode/vs/0'] desc = oven.OVEN_MODE.entities[0] - rep = {'x.com.samsung.da.modes': ['NoOperation']} + assert desc.options(resources) == live_rep['x.com.samsung.da.supportedModes'] for mode in ( 'ConvectionRoast', 'KeepWarm', 'BreadProof', 'AirFryer', 'Dehydrate', 'SelfClean', 'SteamClean', ): - path, body = desc.write_fn(mode, rep) + path, body = desc.write_fn(mode, live_rep) assert path == ['mode', 'vs', '0'] assert body['x.com.samsung.da.modes'] == [mode]