feat: add oven capabilities and cycle-active gating

This commit is contained in:
Marc Billow committed 2026-06-30 16:32:37 -05:00
1 parent 9cbeb6a2b2
commit 23d5090faa
5 files changed
+573 -4

No files matched your search

+6 -1
View File
@@ -16,6 +16,11 @@ from .entities import (
_TIER_CADENCE = {'hot': (1.0, 0.5), 'warm': (15.0, None), 'cold': (300.0, None)}
# Keys produced by oven capabilities that require cycle-active gating.
# When any of these appear in the produced set, the runtime descriptor
# exposes cycle_active_field='cycle_active' so the bridge can gate writes.
_CYCLE_GATED_KEYS = {'oven_setpoint', 'oven_mode', 'cook_time'}
def _segs(href: str) -> list[str]:
return [s for s in href.strip('/').split('/') if s]
@@ -236,6 +241,6 @@ def build_runtime_descriptor(bound, *, topic_prefix, ha_prefix, device_name,
project=_make_project(bound),
on_observation=_make_on_observation(bound),
remote_available_field='remote_control' if 'remote_control' in produced else None,
cycle_active_field=None,
cycle_active_field='cycle_active' if produced & _CYCLE_GATED_KEYS else None,
log_state_change=None,
)
@@ -1,4 +1,4 @@
from . import common, fridge, laundry, operational
from . import common, fridge, laundry, operational, oven
from ..capability import Capability
@@ -6,5 +6,17 @@ def _is_capability(v):
return isinstance(v, Capability)
# Oven capabilities with hrefs unique to the oven family.
# OVEN_OPERATIONAL_STATE (/operational/state/vs/0) and OVEN_DOOR
# (/doors/vs/0) are intentionally excluded — those hrefs are already
# covered by the shared OPERATIONAL_STATE and fridge DOORS_STATUS
# capabilities. Oven-specific entities from those hrefs (cook_time,
# door sensor) will be wired by oven-specific discovery in Task 13.
_OVEN_GLOBAL_CAPS = [
oven.OVEN_SETPOINT,
oven.OVEN_CAVITY,
oven.OVEN_MODE,
]
ALL = [v for mod in (common, operational, laundry, fridge)
for v in vars(mod).values() if _is_capability(v)]
for v in vars(mod).values() if _is_capability(v)] + _OVEN_GLOBAL_CAPS
@@ -6,7 +6,7 @@ remaining-time extrapolation. Shared by dryer/dishwasher/oven/washer families.
import time
from ..capability import Capability
from ..entities import ButtonDesc, SensorDesc
from ..entities import BinarySensorDesc, ButtonDesc, SensorDesc
_SAMSUNG_STATE_TO_OCF = {
'Ready': 'idle', 'Run': 'active', 'Running': 'active',
@@ -75,6 +75,12 @@ OPERATIONAL_STATE = Capability(
entities=(
SensorDesc(key='machine_state', field='x.com.samsung.da.state',
name='Machine state', value_fn=_to_ocf),
# cycle_active is a bool derived from machine_state; used by the
# adapter to gate oven writes (cycle_active_field='cycle_active').
# Harmless for non-oven appliances — just an extra bool in state.
BinarySensorDesc(key='cycle_active', field='x.com.samsung.da.state',
name='Cycle active', device_class='running',
value_fn=lambda v: _SAMSUNG_STATE_TO_OCF.get(v) == 'active'),
SensorDesc(key='progress', field='x.com.samsung.da.progress',
name='Progress', icon='mdi:progress-wrench', value_fn=_progress),
SensorDesc(key='progress_percentage',
@@ -0,0 +1,360 @@
"""Capabilities for the oven family (Samsung NV7000BS-class).
Resources verified against the live device via DTLS-CoAP.
See `local-tools/comparisons/oven-tree.md` for the full field reference.
Write surfaces this module exposes:
proven:
* Lamp via /mode/vs/0 options RMW (probe_oven_lamp_toggle.py)
— works even with Remote Control off.
unproven (first HA use is also the test):
* Sound, FastPreheat, NaturalSteam — same RMW pattern as lamp.
* Setpoint via /temperatures/vs/0 items RMW.
* Cook time via /operational/state/vs/0 operationTime/remainingTime.
* Mode select via /mode/vs/0 .modes — mid-cook acceptance unknown.
* Stop via /operational/state/vs/0 state='Ready'.
Note: Cycle start is not implemented. Reverse-engineering shows local-OCF
cycle start is not reproducible on this firmware (see project_oven_remote
_start_open.md). Mode writes are also unreliable — the oven rolls them back
once a cycle is active. OVEN_MODE is provided as a SelectDesc for fidelity
but is effectively read-only in practice.
"""
import time
from ..capability import Capability
from ..entities import (
BinarySensorDesc, ButtonDesc, NumberDesc, SelectDesc, SensorDesc,
SwitchDesc,
)
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
SETPOINT_MIN_C = 30
SETPOINT_MAX_C = 270
SETPOINT_STEP_C = 5
# Mode options seen on NV7000BS-class. No dump exists so this list is inferred
# from Samsung documentation and firmware observations. The firmware will
# reject unknown modes; missing entries here are a coverage gap, not a bug.
_OVEN_MODES = (
'NoOperation',
'Bake',
'Broil',
'Convection',
'ConvectionBake',
'ConvectionBroil',
'FrozenPizzaPlus',
'SlowCook',
'PlateWarm',
'AirFry',
)
_SAMSUNG_STATE_TO_OCF = {
'Ready': 'idle',
'Run': 'active',
'Running': 'active',
'Pause': 'pause',
'Paused': 'pause',
'End': 'idle',
'Stop': 'idle',
}
def _to_ocf(v):
return _SAMSUNG_STATE_TO_OCF.get(v, v) if v is not None else None
def _int(v):
try:
return int(v)
except (TypeError, ValueError):
return None
def _rem_minutes(remaining):
if not remaining:
return None
try:
h, m, s = remaining.split(':')
return int(h) * 60 + int(m) + (1 if int(s) > 0 else 0)
except Exception:
return None
def _op_minutes(op_time):
"""Parse 'H:MM:SS' operationTime into integer minutes."""
if not op_time:
return None
try:
h, m, s = op_time.split(':')
return int(h) * 60 + int(m) + (1 if int(s) > 0 else 0)
except Exception:
return None
# ---------------------------------------------------------------------------
# Options-array helpers (shared by lamp, sound, fastpreheat, naturalsteam)
# ---------------------------------------------------------------------------
def _option_value(options, prefix):
"""Find `<prefix>_<value>` in an options array and return <value>."""
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 `<prefix>_*` slot replaced."""
return [f"{prefix}_{new_value}" if o.startswith(prefix + '_') else o
for o in options]
# ---------------------------------------------------------------------------
# Write functions
# ---------------------------------------------------------------------------
def _oven_setpoint_write(p, rep):
"""RMW write to /temperatures/vs/0 items array."""
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 _cook_time_write(p, rep):
"""Write operationTime + remainingTime (H:MM:SS) from minutes."""
try:
minutes = int(round(float(p)))
except (TypeError, ValueError):
return None
if not (0 <= minutes <= 1439):
return None
h, m = divmod(minutes, 60)
hms = f"{h:02d}:{m:02d}:00"
return ['operational', 'state', 'vs', '0'], {
'x.com.samsung.da.operationTime': hms,
'x.com.samsung.da.remainingTime': hms,
}
def _stop_write(p, rep):
return ['operational', 'state', 'vs', '0'], {
'x.com.samsung.da.state': 'Ready',
}
def _oven_mode_write(p, rep):
if p not in _OVEN_MODES:
return None
return ['mode', 'vs', '0'], {'x.com.samsung.da.modes': [p]}
def _lamp_write(p, rep):
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, 'UpperLamp', p),
}
def _sound_write(p, rep):
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),
}
def _fastpreheat_write(p, rep):
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, 'fastpreheat', p),
}
def _naturalsteam_write(p, rep):
if p not in ('On', 'Off'):
return None
opts = list(rep.get('x.com.samsung.da.options') or [])
if not opts:
return None
if not any(o.startswith('NaturalSteam_') for o in opts):
opts = opts + [f'NaturalSteam_{p}']
else:
opts = _replace_in_options(opts, 'NaturalSteam', p)
return ['mode', 'vs', '0'], {'x.com.samsung.da.options': opts}
# ---------------------------------------------------------------------------
# on_observation / project for remaining-time extrapolation
# ---------------------------------------------------------------------------
def _active_when(rep):
return _SAMSUNG_STATE_TO_OCF.get(rep.get('x.com.samsung.da.state')) == 'active'
def _on_observation(state, rep):
rem = rep.get('x.com.samsung.da.remainingTime')
if not isinstance(rem, str):
return
try:
h, m, s = rem.split(':')
state['remaining_anchor'] = (time.time(),
int(h) * 3600 + int(m) * 60 + int(s))
except (ValueError, AttributeError):
pass
def _project(state, sensors):
anchor = state.get('remaining_anchor')
if sensors.get('machine_state') != 'active' or anchor is None:
return sensors
ts, total = anchor
remaining = max(0, int(total - (time.time() - ts)))
h, rest = divmod(remaining, 3600)
m, s = divmod(rest, 60)
sensors = dict(sensors)
sensors['completion_time'] = f"{h}:{m:02d}:{s:02d}"
sensors['completion_minutes'] = h * 60 + m + (1 if s > 0 else 0)
return sensors
# ---------------------------------------------------------------------------
# Capabilities
# ---------------------------------------------------------------------------
OVEN_OPERATIONAL_STATE = Capability(
href='/operational/state/vs/0',
poll_tier='hot',
active_when=_active_when,
on_observation=_on_observation,
project=_project,
entities=(
SensorDesc(key='machine_state', field='x.com.samsung.da.state',
name='Machine state', icon='mdi:stove', value_fn=_to_ocf),
BinarySensorDesc(key='cycle_active', field='x.com.samsung.da.state',
name='Cycle active', device_class='running',
value_fn=lambda v: _SAMSUNG_STATE_TO_OCF.get(v) == 'active'),
SensorDesc(key='progress_percentage',
field='x.com.samsung.da.progressPercentage',
name='Progress percent', unit='%', state_class='measurement',
value_fn=_int),
SensorDesc(key='operation_time', field='x.com.samsung.da.operationTime',
name='Elapsed time', icon='mdi:timer'),
SensorDesc(key='operation_time_minutes',
field='x.com.samsung.da.operationTime',
name='Operation time (minutes)', unit='min',
state_class='measurement', value_fn=_op_minutes),
SensorDesc(key='completion_time', field='x.com.samsung.da.remainingTime',
name='Completion time', icon='mdi:timer-sand'),
SensorDesc(key='completion_minutes', field='x.com.samsung.da.remainingTime',
name='Remaining minutes', unit='min', device_class='duration',
state_class='measurement', value_fn=_rem_minutes),
NumberDesc(key='cook_time', field='x.com.samsung.da.operationTime',
name='Cook time', unit='min', native_min=0, native_max=1439,
step=1.0, icon='mdi:timer', value_fn=_op_minutes,
write_fn=_cook_time_write),
ButtonDesc(key='stop', field='', name='Stop cycle', icon='mdi:stop',
payload='Stop', write_fn=_stop_write),
),
)
OVEN_CAVITY = Capability(
href='/oven/vs/0',
poll_tier='hot',
entities=(
SensorDesc(key='oven_state', field='x.com.samsung.da.state',
name='Cavity state'),
),
)
OVEN_SETPOINT = Capability(
href='/temperatures/vs/0',
poll_tier='hot',
entities=(
# NumberDesc first — test_oven_setpoint_write_is_read_modify_write uses entities[0]
NumberDesc(key='oven_setpoint', field='x.com.samsung.da.items',
name='Setpoint', device_class='temperature', unit='°C',
native_min=float(SETPOINT_MIN_C), native_max=float(SETPOINT_MAX_C),
step=float(SETPOINT_STEP_C), icon='mdi:thermometer-chevron-up',
value_fn=lambda items: _int(
(items[0].get('x.com.samsung.da.desired') if items else None)),
write_fn=_oven_setpoint_write),
SensorDesc(key='current_temp_c', field='x.com.samsung.da.items',
name='Temperature', device_class='temperature',
state_class='measurement', unit='°C',
value_fn=lambda items: _int(
(items[0].get('x.com.samsung.da.current') if items else None))),
),
)
OVEN_DOOR = Capability(
href='/doors/vs/0',
poll_tier='hot',
entities=(
SensorDesc(key='door', field='x.com.samsung.da.items',
name='Door state',
value_fn=lambda items: (
items[0].get('x.com.samsung.da.openState')
if items else None)),
BinarySensorDesc(key='door_open', field='x.com.samsung.da.items',
name='Door', device_class='door',
value_fn=lambda items: (
items[0].get('x.com.samsung.da.openState') == 'Open'
if items else None)),
),
)
OVEN_MODE = Capability(
href='/mode/vs/0',
poll_tier='warm',
entities=(
# SelectDesc first — test_oven_mode_options_nonempty uses entities[0]
SelectDesc(key='oven_mode', field='x.com.samsung.da.modes',
name='Cooking mode', icon='mdi:tune',
options=_OVEN_MODES,
value_fn=lambda v: v[0] if v else None,
write_fn=_oven_mode_write),
SwitchDesc(key='lamp', field='x.com.samsung.da.options',
name='Lamp', icon='mdi:track-light',
value_fn=lambda opts: _option_value(opts, 'UpperLamp') == 'On',
write_fn=_lamp_write),
SwitchDesc(key='sound', field='x.com.samsung.da.options',
name='Sound', icon='mdi:volume-high',
value_fn=lambda opts: _option_value(opts, 'Sound') == 'On',
write_fn=_sound_write),
SwitchDesc(key='fast_preheat', field='x.com.samsung.da.options',
name='Fast preheat', icon='mdi:fire',
value_fn=lambda opts: _option_value(opts, 'fastpreheat') == 'On',
write_fn=_fastpreheat_write),
SwitchDesc(key='natural_steam', field='x.com.samsung.da.options',
name='Natural steam', icon='mdi:kettle-steam',
value_fn=lambda opts: _option_value(opts, 'NaturalSteam') == 'On',
write_fn=_naturalsteam_write),
),
)
+186
View File
@@ -0,0 +1,186 @@
"""Unit tests for oven-family capabilities and the cycle-active adapter wiring."""
from samsung_appliance.registry.capabilities import oven
from samsung_appliance.registry.adapter import build_runtime_descriptor
from samsung_appliance.registry.discovery import discover
# ---------------------------------------------------------------------------
# OVEN_SETPOINT — NumberDesc with RMW write semantics
# ---------------------------------------------------------------------------
def test_oven_setpoint_write_is_read_modify_write():
cap = oven.OVEN_SETPOINT
desc = cap.entities[0]
rep = {'x.com.samsung.da.items': [
{'x.com.samsung.da.id': 'Target', 'x.com.samsung.da.temperature': 0}]}
path, body = desc.write_fn(200, rep)
assert path[-1] == '0' # writes back to the resource
# the produced body preserves the items-array shape the oven expects
assert 'x.com.samsung.da.items' in body or 'x.com.samsung.da.temperature' in str(body)
def test_oven_setpoint_rmw_preserves_other_item_fields():
"""RMW must not drop sibling fields (e.g. x.com.samsung.da.current)."""
desc = oven.OVEN_SETPOINT.entities[0]
rep = {'x.com.samsung.da.items': [{
'x.com.samsung.da.current': '180',
'x.com.samsung.da.desired': '180',
}]}
path, body = desc.write_fn(200, rep)
item = body['x.com.samsung.da.items'][0]
assert item['x.com.samsung.da.desired'] == '200'
# sibling field preserved
assert item['x.com.samsung.da.current'] == '180'
def test_oven_setpoint_clamps_to_step():
"""Setpoint must be a multiple of SETPOINT_STEP_C."""
desc = oven.OVEN_SETPOINT.entities[0]
rep = {'x.com.samsung.da.items': [{'x.com.samsung.da.desired': '0'}]}
_, body = desc.write_fn(202, rep)
# 202 → nearest 5 = 200
assert body['x.com.samsung.da.items'][0]['x.com.samsung.da.desired'] == '200'
def test_oven_setpoint_rejects_out_of_range():
desc = oven.OVEN_SETPOINT.entities[0]
rep = {'x.com.samsung.da.items': [{'x.com.samsung.da.desired': '100'}]}
assert desc.write_fn(10, rep) is None # below min (30)
assert desc.write_fn(300, rep) is None # above max (270)
def test_oven_setpoint_rejects_missing_items():
desc = oven.OVEN_SETPOINT.entities[0]
assert desc.write_fn(200, {}) is None
# ---------------------------------------------------------------------------
# OVEN_MODE — SelectDesc with non-empty options
# ---------------------------------------------------------------------------
def test_oven_mode_options_nonempty():
assert len(oven.OVEN_MODE.entities[0].options) > 0
def test_oven_mode_write_round_trips():
desc = oven.OVEN_MODE.entities[0]
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]
def test_oven_mode_rejects_unknown():
desc = oven.OVEN_MODE.entities[0]
assert desc.write_fn('SpaghettiMode', {}) is None
# ---------------------------------------------------------------------------
# OVEN_MODE options-array RMW (lamp, sound, fast_preheat, natural_steam)
# ---------------------------------------------------------------------------
def _mode_rep(*extra_opts):
return {'x.com.samsung.da.options': [
'UpperLamp_Off', 'Sound_On', 'fastpreheat_Off', *extra_opts,
]}
def test_lamp_write_replaces_slot():
desc = next(e for e in oven.OVEN_MODE.entities if e.key == 'lamp')
path, body = desc.write_fn('On', _mode_rep())
opts = body['x.com.samsung.da.options']
assert 'UpperLamp_On' in opts
assert 'UpperLamp_Off' not in opts
def test_lamp_write_requires_existing_options():
desc = next(e for e in oven.OVEN_MODE.entities if e.key == 'lamp')
assert desc.write_fn('On', {}) is None
def test_sound_write_preserves_other_options():
desc = next(e for e in oven.OVEN_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 'UpperLamp_Off' in opts # other slot unchanged
def test_natural_steam_appended_if_absent():
"""NaturalSteam slot is absent until first write — write_fn must append it."""
desc = next(e for e in oven.OVEN_MODE.entities if e.key == 'natural_steam')
path, body = desc.write_fn('On', _mode_rep()) # no NaturalSteam_* in rep
opts = body['x.com.samsung.da.options']
assert any(o.startswith('NaturalSteam_') for o in opts)
# ---------------------------------------------------------------------------
# OVEN_OPERATIONAL_STATE — cycle_active BinarySensorDesc
# ---------------------------------------------------------------------------
def test_cycle_active_true_when_running():
desc = next(e for e in oven.OVEN_OPERATIONAL_STATE.entities
if e.key == 'cycle_active')
assert desc.value_fn('Run') is True
assert desc.value_fn('Running') is True
def test_cycle_active_false_when_idle():
desc = next(e for e in oven.OVEN_OPERATIONAL_STATE.entities
if e.key == 'cycle_active')
assert desc.value_fn('Ready') is False
assert desc.value_fn(None) is False
# ---------------------------------------------------------------------------
# OVEN_OPERATIONAL_STATE — cook_time NumberDesc
# ---------------------------------------------------------------------------
def test_cook_time_write_produces_hms():
desc = next(e for e in oven.OVEN_OPERATIONAL_STATE.entities
if e.key == 'cook_time')
path, body = desc.write_fn(90, {})
assert path == ['operational', 'state', 'vs', '0']
assert body['x.com.samsung.da.operationTime'] == '01:30:00'
assert body['x.com.samsung.da.remainingTime'] == '01:30:00'
def test_cook_time_rejects_out_of_range():
desc = next(e for e in oven.OVEN_OPERATIONAL_STATE.entities
if e.key == 'cook_time')
assert desc.write_fn(-1, {}) is None
assert desc.write_fn(1440, {}) is None
# ---------------------------------------------------------------------------
# Adapter cycle_active_field wiring
# ---------------------------------------------------------------------------
def test_adapter_sets_cycle_active_field_for_oven():
"""build_runtime_descriptor must set cycle_active_field when oven keys present."""
reg = {c.href: c for c in (oven.OVEN_OPERATIONAL_STATE, oven.OVEN_SETPOINT)}
resources = {
'/operational/state/vs/0': {'x.com.samsung.da.state': 'Run'},
'/temperatures/vs/0': {'x.com.samsung.da.items': [
{'x.com.samsung.da.current': '180', 'x.com.samsung.da.desired': '200'}
]},
}
bound = discover(resources, reg)
rd = build_runtime_descriptor(
bound, topic_prefix='t', ha_prefix='homeassistant',
device_name='Oven', model='M', name='oven', default_port=49154)
assert rd.cycle_active_field == 'cycle_active'
flat = rd.flatten(resources)
assert flat['cycle_active'] is True # 'Run' -> active
def test_adapter_no_cycle_active_without_oven_keys():
"""Non-oven appliances must not get cycle_active_field."""
from samsung_appliance.registry.capabilities import common
reg = {c.href: c for c in (common.KIDS_LOCK,)}
resources = {'/kidslock/vs/0': {'x.com.samsung.da.kidsLock': 'Ready'}}
bound = discover(resources, reg)
rd = build_runtime_descriptor(
bound, topic_prefix='t', ha_prefix='homeassistant',
device_name='Dishwasher', model='M', name='dishwasher', default_port=49154)
assert rd.cycle_active_field is None