From 23d5090faa5f36b33c65f88012580c75d4b11ac1 Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Sun, 28 Jun 2026 15:14:06 -0500 Subject: [PATCH] feat: add oven capabilities and cycle-active gating --- samsung_appliance/registry/adapter.py | 7 +- .../registry/capabilities/__init__.py | 16 +- .../registry/capabilities/operational.py | 8 +- .../registry/capabilities/oven.py | 360 ++++++++++++++++++ tests/test_oven_capabilities.py | 186 +++++++++ 5 files changed, 573 insertions(+), 4 deletions(-) create mode 100644 samsung_appliance/registry/capabilities/oven.py create mode 100644 tests/test_oven_capabilities.py diff --git a/samsung_appliance/registry/adapter.py b/samsung_appliance/registry/adapter.py index ebfdaf0..1b4c7f9 100644 --- a/samsung_appliance/registry/adapter.py +++ b/samsung_appliance/registry/adapter.py @@ -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, ) diff --git a/samsung_appliance/registry/capabilities/__init__.py b/samsung_appliance/registry/capabilities/__init__.py index 0f5016f..7cdc427 100644 --- a/samsung_appliance/registry/capabilities/__init__.py +++ b/samsung_appliance/registry/capabilities/__init__.py @@ -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 diff --git a/samsung_appliance/registry/capabilities/operational.py b/samsung_appliance/registry/capabilities/operational.py index 1320e86..828711d 100644 --- a/samsung_appliance/registry/capabilities/operational.py +++ b/samsung_appliance/registry/capabilities/operational.py @@ -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', diff --git a/samsung_appliance/registry/capabilities/oven.py b/samsung_appliance/registry/capabilities/oven.py new file mode 100644 index 0000000..75cf141 --- /dev/null +++ b/samsung_appliance/registry/capabilities/oven.py @@ -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 `_` in an options array and return .""" + 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 `_*` 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), + ), +) diff --git a/tests/test_oven_capabilities.py b/tests/test_oven_capabilities.py new file mode 100644 index 0000000..f4983f4 --- /dev/null +++ b/tests/test_oven_capabilities.py @@ -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