feat: add oven capabilities and cycle-active gating
This commit is contained in:
1 parent
9cbeb6a2b2
commit
23d5090faa
5 files changed
+573
-4
No files matched your search
@@ -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),
|
||||
),
|
||||
)
|
||||
@@ -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
|
||||
Reference in new issue
Block a user