Read oven cook modes live from the device instead of a hardcoded list

Follow-up to the issue #138 fix: rather than hand-adding
ConvectionRoast/KeepWarm/BreadProof/AirFryer/Dehydrate/SelfClean/SteamClean
to oven._OVEN_MODES, read them from the device's own /mode/vs/0
supportedModes when it reports one, falling back to the static
NV7000BS-era guess only when it doesn't. Matches the adding-device-support
skill's preference for device-reported option lists over hardcoded ones,
and the SelectDesc's write validation now checks the same live list it
displays instead of a separate static tuple.
This commit is contained in:
Marc Billow committed 2026-07-28 13:26:41 +00:00
1 parent 1fb27c30ae
commit 1cdad7f84c
3 files changed
+65 -24

No files matched your search

@@ -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',
+32 -2
View File
@@ -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
+13 -6
View File
@@ -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]