From 6e5fee32b8f3e45bd84e4a14e5a12960204647f3 Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Thu, 23 Jul 2026 02:14:40 +0000 Subject: [PATCH 1/6] feat: reject writes when a device's remote control is disabled Devices with a /remotectrl href already surface it as a read-only "Smart Control" binary sensor, but writes weren't checking it before now. async_send_command now blocks every write (any platform) with a ServiceValidationError telling the user to enable remote control via the appliance's manual, ahead of any per-description validate_fn. --- custom_components/localthings/coordinator.py | 29 +++- tests/localthings/test_coordinator.py | 138 ++++++++++++++++++- 2 files changed, 164 insertions(+), 3 deletions(-) diff --git a/custom_components/localthings/coordinator.py b/custom_components/localthings/coordinator.py index 39c401b..359a598 100644 --- a/custom_components/localthings/coordinator.py +++ b/custom_components/localthings/coordinator.py @@ -37,6 +37,26 @@ _LOGGER = logging.getLogger(__name__) _SEED_PATH = ['device', '0'] +_REMOTE_CONTROL_DISABLED_MESSAGE = ( + "Remote control is turned off on this device. Check your appliance's " + "manual for how to enable remote control before Home Assistant can " + "control it." +) + + +def _remote_control_disabled(resources: dict) -> bool: + """True only when the device explicitly reports remote control off. + Mirrors REMOTE_CONTROL_GENERIC/_VS_FALLBACK's href/field pair in + registry/capabilities/common.py. Fails open (False) when neither href + is present -- most device types don't report this capability at all.""" + generic = resources.get('/remotectrl/0') + if generic is not None: + return not bool(generic.get('value')) + fallback = resources.get('/remotectrl/vs/0') + if fallback is not None: + return str(fallback.get('x.com.samsung.da.remoteControlEnabled')).lower() != 'true' + return False + class _NoOpDescriptor: """StateCache requires a descriptor with an on_observation hook. This @@ -515,16 +535,21 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]): A description-level validate_fn (currently SwitchDesc only) runs here rather than per-platform, so rejecting a write with a user-facing message -- as opposed to write_fn's silent no-op below - -- is available to every platform for free.""" + -- is available to every platform for free. The remote-control + check runs first and applies to every platform unconditionally, + ahead of any description-specific validate_fn.""" desc = bound_entity.desc write_fn = getattr(desc, 'write_fn', None) if write_fn is None: return href = bound_entity.href rep = self._cache.get(href or '') or {} + resources = self._cache.snapshot() + if _remote_control_disabled(resources): + raise ServiceValidationError(_REMOTE_CONTROL_DISABLED_MESSAGE) validate_fn = getattr(desc, 'validate_fn', None) if validate_fn is not None: - error = validate_fn(payload, rep, self._cache.snapshot()) + error = validate_fn(payload, rep, resources) if error: raise ServiceValidationError(error) result = write_fn(payload, rep, href) diff --git a/tests/localthings/test_coordinator.py b/tests/localthings/test_coordinator.py index 7739da5..ed199e2 100644 --- a/tests/localthings/test_coordinator.py +++ b/tests/localthings/test_coordinator.py @@ -7,12 +7,15 @@ from unittest.mock import AsyncMock, patch import cbor2 import pytest from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import issue_registry as ir from custom_components.localthings.const import ( CONF_HOST, DOMAIN, SUMMARY_INTERVAL_S, ) -from custom_components.localthings.coordinator import LocalThingsCoordinator +from custom_components.localthings.coordinator import ( + LocalThingsCoordinator, _remote_control_disabled, +) from custom_components.localthings.observe import MODE_OBSERVE, MODE_POLL, PUSH_HEALTH_WINDOW_S from .conftest import ENTRY_DATA, MOCK_SERIAL @@ -634,3 +637,136 @@ async def test_write_marks_href_pending_before_post( await coordinator.async_send_command(bound, 5) assert coordinator._observe._settle_until.get('/test/vs/0') is not None + + +class TestRemoteControlDisabled: + """_remote_control_disabled mirrors REMOTE_CONTROL_GENERIC/_VS_FALLBACK's + OCF-standard-preferred read (registry/capabilities/common.py) directly + on a raw resources snapshot.""" + + def test_vs_fallback_href_true_is_enabled(self): + resources = {'/remotectrl/vs/0': {'x.com.samsung.da.remoteControlEnabled': 'true'}} + assert _remote_control_disabled(resources) is False + + def test_vs_fallback_href_false_is_disabled(self): + resources = {'/remotectrl/vs/0': {'x.com.samsung.da.remoteControlEnabled': 'false'}} + assert _remote_control_disabled(resources) is True + + def test_generic_href_true_is_enabled(self): + resources = {'/remotectrl/0': {'value': True}} + assert _remote_control_disabled(resources) is False + + def test_generic_href_false_is_disabled(self): + resources = {'/remotectrl/0': {'value': False}} + assert _remote_control_disabled(resources) is True + + def test_generic_href_wins_when_both_present(self): + resources = { + '/remotectrl/0': {'value': True}, + '/remotectrl/vs/0': {'x.com.samsung.da.remoteControlEnabled': 'false'}, + } + assert _remote_control_disabled(resources) is False + + def test_fails_open_when_capability_absent(self): + assert _remote_control_disabled({}) is False + + +async def test_send_command_blocked_when_remote_control_disabled( + hass: HomeAssistant, mock_entry, mock_coordinator_observe_session +) -> None: + """A device reporting remote control off must reject every write with + a user-facing message, ahead of write_fn's silent-no-op path and any + description-level validate_fn.""" + from custom_components.localthings.registry.discovery import BoundEntity + from custom_components.localthings.registry.entities import NumberDesc + + fake = mock_coordinator_observe_session + await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() + coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id] + coordinator._cache.apply_rep( + '/remotectrl/vs/0', + {'x.com.samsung.da.remoteControlEnabled': 'false'}, + source='test', + ) + + def _write_fn(payload, rep, href): + return (['some', 'path'], {'value': payload}) + + desc = NumberDesc(key='test', field='value', write_fn=_write_fn) + bound = BoundEntity(href='/test/vs/0', capability=coordinator.bound[0].capability, desc=desc) + + posted = False + + def _post(*a, **k): + nonlocal posted + posted = True + return (0x44, b'') + + with patch.object(fake, 'subscribe'): + fake.post = _post + with pytest.raises(ServiceValidationError, match="Remote control is turned off"): + await coordinator.async_send_command(bound, 5) + + assert posted is False + + +async def test_send_command_allowed_when_remote_control_enabled( + hass: HomeAssistant, mock_entry, mock_coordinator_observe_session +) -> None: + """The same write goes through once remote control reports enabled.""" + from custom_components.localthings.registry.discovery import BoundEntity + from custom_components.localthings.registry.entities import NumberDesc + + fake = mock_coordinator_observe_session + await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() + coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id] + coordinator._cache.apply_rep( + '/remotectrl/vs/0', + {'x.com.samsung.da.remoteControlEnabled': 'true'}, + source='test', + ) + + def _write_fn(payload, rep, href): + return (['some', 'path'], {'value': payload}) + + desc = NumberDesc(key='test', field='value', write_fn=_write_fn) + bound = BoundEntity(href='/test/vs/0', capability=coordinator.bound[0].capability, desc=desc) + + with patch.object(fake, 'subscribe'): + fake.post = lambda *a, **k: (0x44, b'') + await coordinator.async_send_command(bound, 5) + + assert coordinator._observe._settle_until.get('/test/vs/0') is not None + + +async def test_send_command_remote_control_check_precedes_validate_fn( + hass: HomeAssistant, mock_entry, mock_coordinator_observe_session +) -> None: + """When both a disabled remote-control state and a failing validate_fn + apply to the same write, the remote-control message wins -- it's the + more actionable of the two and is checked first.""" + from custom_components.localthings.registry.discovery import BoundEntity + from custom_components.localthings.registry.entities import SwitchDesc + + await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() + coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id] + coordinator._cache.apply_rep( + '/remotectrl/vs/0', + {'x.com.samsung.da.remoteControlEnabled': 'false'}, + source='test', + ) + + def _write_fn(payload, rep, href=None): + return (['some', 'path'], {'value': payload}) + + def _validate_fn(payload, rep, resources): + return "always rejected" + + desc = SwitchDesc(key='test', field='value', write_fn=_write_fn, validate_fn=_validate_fn) + bound = BoundEntity(href='/test/vs/0', capability=coordinator.bound[0].capability, desc=desc) + + with pytest.raises(ServiceValidationError, match="Remote control is turned off"): + await coordinator.async_send_command(bound, 'On') From 2f6b7d54e328242ab3b0c27318020a74748da9f1 Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Thu, 23 Jul 2026 02:29:03 +0000 Subject: [PATCH 2/6] refactor: share remote-control read between sensor and write guard, poll it warm Move the on/off interpretation into a single remote_control_enabled() in registry/capabilities/common.py so the write-guard added in the previous commit can't silently drift from the Smart Control binary sensor's own reading of the same hrefs. Also promotes both /remotectrl hrefs to poll_tier='warm' so the coordinator's cached state backing that write guard doesn't lag up to a full 30s cold summary poll behind the device's actual toggle state. --- custom_components/localthings/coordinator.py | 17 ++--------- .../registry/capabilities/common.py | 23 +++++++++++++++ tests/localthings/test_coordinator.py | 28 ++++++++++--------- tests/test_common_capabilities.py | 8 ++++++ 4 files changed, 48 insertions(+), 28 deletions(-) diff --git a/custom_components/localthings/coordinator.py b/custom_components/localthings/coordinator.py index 359a598..cb2f8b1 100644 --- a/custom_components/localthings/coordinator.py +++ b/custom_components/localthings/coordinator.py @@ -22,6 +22,7 @@ from smartthings_local.ocf.state_cache import StateCache from .registry.batch import parse_device0_batch from .registry.by_type import for_device, for_device_by_model +from .registry.capabilities.common import remote_control_enabled from .registry.discovery import discover, BoundEntity from .registry import CAPABILITIES from .registry.adapter import flatten @@ -44,20 +45,6 @@ _REMOTE_CONTROL_DISABLED_MESSAGE = ( ) -def _remote_control_disabled(resources: dict) -> bool: - """True only when the device explicitly reports remote control off. - Mirrors REMOTE_CONTROL_GENERIC/_VS_FALLBACK's href/field pair in - registry/capabilities/common.py. Fails open (False) when neither href - is present -- most device types don't report this capability at all.""" - generic = resources.get('/remotectrl/0') - if generic is not None: - return not bool(generic.get('value')) - fallback = resources.get('/remotectrl/vs/0') - if fallback is not None: - return str(fallback.get('x.com.samsung.da.remoteControlEnabled')).lower() != 'true' - return False - - class _NoOpDescriptor: """StateCache requires a descriptor with an on_observation hook. This integration doesn't use per-capability observation hooks, so this is a @@ -545,7 +532,7 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]): href = bound_entity.href rep = self._cache.get(href or '') or {} resources = self._cache.snapshot() - if _remote_control_disabled(resources): + if not remote_control_enabled(resources): raise ServiceValidationError(_REMOTE_CONTROL_DISABLED_MESSAGE) validate_fn = getattr(desc, 'validate_fn', None) if validate_fn is not None: diff --git a/custom_components/localthings/registry/capabilities/common.py b/custom_components/localthings/registry/capabilities/common.py index ef181fc..bb1775b 100644 --- a/custom_components/localthings/registry/capabilities/common.py +++ b/custom_components/localthings/registry/capabilities/common.py @@ -119,8 +119,30 @@ KIDS_LOCK_VS_FALLBACK = Capability( ), ) + +def remote_control_enabled(resources: dict) -> bool: + """Single source of truth for the /remotectrl on/off signal, mirroring + REMOTE_CONTROL_GENERIC/_VS_FALLBACK's href/field pair and precedence + below. Used both to render the read-only Smart Control binary_sensor + (via those two descriptors) and, from coordinator.async_send_command, + to block writes outright when remote control is off. Both hrefs are + poll_tier='warm' below so that gate reads recent state (subscribed + when observe is live, subpolled every ~6s otherwise) rather than a + once-per-30s cold summary poll. True (assume enabled) when neither + href is present -- most device types don't report this capability + at all.""" + generic = resources.get('/remotectrl/0') + if generic is not None: + return bool(generic.get('value')) + fallback = resources.get('/remotectrl/vs/0') + if fallback is not None: + return str(fallback.get('x.com.samsung.da.remoteControlEnabled')).lower() == 'true' + return True + + REMOTE_CONTROL_GENERIC = Capability( href='/remotectrl/0', + poll_tier='warm', entities=( BinarySensorDesc(key='remote_control', field='value', name='Smart Control', device_class='connectivity', @@ -131,6 +153,7 @@ REMOTE_CONTROL_GENERIC = Capability( REMOTE_CONTROL_VS_FALLBACK = Capability( href='/remotectrl/vs/0', match_fn=lambda rep, resources: '/remotectrl/0' not in resources, + poll_tier='warm', entities=( BinarySensorDesc(key='remote_control', field='x.com.samsung.da.remoteControlEnabled', diff --git a/tests/localthings/test_coordinator.py b/tests/localthings/test_coordinator.py index ed199e2..0a5c2cb 100644 --- a/tests/localthings/test_coordinator.py +++ b/tests/localthings/test_coordinator.py @@ -13,8 +13,9 @@ from homeassistant.helpers import issue_registry as ir from custom_components.localthings.const import ( CONF_HOST, DOMAIN, SUMMARY_INTERVAL_S, ) -from custom_components.localthings.coordinator import ( - LocalThingsCoordinator, _remote_control_disabled, +from custom_components.localthings.coordinator import LocalThingsCoordinator +from custom_components.localthings.registry.capabilities.common import ( + remote_control_enabled, ) from custom_components.localthings.observe import MODE_OBSERVE, MODE_POLL, PUSH_HEALTH_WINDOW_S @@ -639,36 +640,37 @@ async def test_write_marks_href_pending_before_post( assert coordinator._observe._settle_until.get('/test/vs/0') is not None -class TestRemoteControlDisabled: - """_remote_control_disabled mirrors REMOTE_CONTROL_GENERIC/_VS_FALLBACK's - OCF-standard-preferred read (registry/capabilities/common.py) directly - on a raw resources snapshot.""" +class TestRemoteControlEnabled: + """remote_control_enabled (registry/capabilities/common.py) is the + single source of truth for the /remotectrl on/off signal, shared by + the Smart Control binary_sensor descriptors and the coordinator's + write guard alike.""" def test_vs_fallback_href_true_is_enabled(self): resources = {'/remotectrl/vs/0': {'x.com.samsung.da.remoteControlEnabled': 'true'}} - assert _remote_control_disabled(resources) is False + assert remote_control_enabled(resources) is True def test_vs_fallback_href_false_is_disabled(self): resources = {'/remotectrl/vs/0': {'x.com.samsung.da.remoteControlEnabled': 'false'}} - assert _remote_control_disabled(resources) is True + assert remote_control_enabled(resources) is False def test_generic_href_true_is_enabled(self): resources = {'/remotectrl/0': {'value': True}} - assert _remote_control_disabled(resources) is False + assert remote_control_enabled(resources) is True def test_generic_href_false_is_disabled(self): resources = {'/remotectrl/0': {'value': False}} - assert _remote_control_disabled(resources) is True + assert remote_control_enabled(resources) is False def test_generic_href_wins_when_both_present(self): resources = { '/remotectrl/0': {'value': True}, '/remotectrl/vs/0': {'x.com.samsung.da.remoteControlEnabled': 'false'}, } - assert _remote_control_disabled(resources) is False + assert remote_control_enabled(resources) is True - def test_fails_open_when_capability_absent(self): - assert _remote_control_disabled({}) is False + def test_assumes_enabled_when_capability_absent(self): + assert remote_control_enabled({}) is True async def test_send_command_blocked_when_remote_control_disabled( diff --git a/tests/test_common_capabilities.py b/tests/test_common_capabilities.py index fdd1150..473d64d 100644 --- a/tests/test_common_capabilities.py +++ b/tests/test_common_capabilities.py @@ -84,6 +84,14 @@ class TestRemoteControlFallback: assert common.REMOTE_CONTROL_VS_FALLBACK.match_fn( {}, {'/remotectrl/0': {}, '/remotectrl/vs/0': {}}) is False + def test_polled_warm_so_write_gating_stays_fresh(self): + """coordinator.async_send_command blocks writes on this signal, so + it can't sit in the default 'cold' tier (refreshed only once per + 30s summary poll) -- it needs the subscribe/subpoll cadence 'warm' + and 'hot' hrefs get instead.""" + assert common.REMOTE_CONTROL_GENERIC.poll_tier == 'warm' + assert common.REMOTE_CONTROL_VS_FALLBACK.poll_tier == 'warm' + # --------------------------------------------------------------------------- # Energy meter. instantaneousPower clamps negatives to 0, but the constant From 321f186b0edcc1c0211035ff7d7b9985832aa1d0 Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Thu, 23 Jul 2026 01:45:32 +0000 Subject: [PATCH 3/6] Add range/cooktop-oven combo support (issue #44) TP1X_DA-KS-RANGE-0102X (model NSI6DG9100SRAA) reports no oneUiVersion and previously fell through to the unknown-device fallback, leaving /connected, /cooktop/spec, /cooktop/settings/status, /cooktop/status, and /oven/spec unbound. Add a 'range' device registry that reuses the oven family's cavity/setpoint/mode/operational-state/door capabilities and adds a new cooktop.py module modeling per-burner power level, state, and hot-surface entities (gated so unreported burner slots don't appear), plus a hot-surface auto-shutoff config sensor. Route range/cooktop models to it via a '-RANGE-' modelNum token, mirroring the existing RAC/PRAC air-conditioner fallback pattern. The dryer (DA_WM_TP1_21_COMMON) pause/stop buttons mentioned in the same issue are working as intended -- the reporter confirmed that's an expected in-person-only limitation, not a bug. --- .../localthings/registry/by_type/__init__.py | 8 +- .../localthings/registry/by_type/range.py | 28 ++ .../registry/capabilities/cooktop.py | 124 +++++ .../localthings/registry/capabilities/oven.py | 7 + tests/fixtures/golden/range.json | 39 ++ tests/fixtures/range_device.json | 466 ++++++++++++++++++ tests/test_golden_regression.py | 17 + tests/test_range_capabilities.py | 84 ++++ 8 files changed, 772 insertions(+), 1 deletion(-) create mode 100644 custom_components/localthings/registry/by_type/range.py create mode 100644 custom_components/localthings/registry/capabilities/cooktop.py create mode 100644 tests/fixtures/golden/range.json create mode 100644 tests/fixtures/range_device.json create mode 100644 tests/test_range_capabilities.py diff --git a/custom_components/localthings/registry/by_type/__init__.py b/custom_components/localthings/registry/by_type/__init__.py index 2dcd79b..a7e77bd 100644 --- a/custom_components/localthings/registry/by_type/__init__.py +++ b/custom_components/localthings/registry/by_type/__init__.py @@ -2,7 +2,7 @@ from typing import Optional from ._base import DeviceRegistry -from . import airconditioner, dishwasher, dryer, oven, refrigerator, washer +from . import airconditioner, dishwasher, dryer, oven, range as _range, refrigerator, washer __all__ = ['DeviceRegistry', '_type_key', 'for_device', 'for_device_by_model'] @@ -13,6 +13,7 @@ _REGISTRY_BY_KEY: dict[str, DeviceRegistry] = { 'dishwasher': dishwasher.REGISTRY, 'dryer': dryer.REGISTRY, 'oven': oven.REGISTRY, + 'range': _range.REGISTRY, 'refrigerator': refrigerator.REGISTRY, 'washer': washer.REGISTRY, } @@ -101,4 +102,9 @@ def for_device_by_model(model_num: str, description: str) -> Optional[DeviceRegi # 'P' sits between the underscore and 'RAC' in that token). if key is None and '_RAC_' in (model_num or ''): key = 'airconditioner' + # Range/cooktop-oven combos (e.g. TP1X_DA-KS-RANGE-0102X, issue #44) -- + # like the RAC/PRAC air conditioners above, these report no oneUiVersion + # and don't match the washer/dryer/dishwasher consumer-prefix map either. + if key is None and '-RANGE-' in (model_num or '').upper(): + key = 'range' return _REGISTRY_BY_KEY.get(key) if key else None diff --git a/custom_components/localthings/registry/by_type/range.py b/custom_components/localthings/registry/by_type/range.py new file mode 100644 index 0000000..fe50bcf --- /dev/null +++ b/custom_components/localthings/registry/by_type/range.py @@ -0,0 +1,28 @@ +"""Range (oven + cooktop combo) device registry — issue #44. + +Reuses the oven family's cavity/setpoint/mode/operational-state/door/ +connected capabilities wholesale (a range's oven half is the same OCF +surface as a standalone oven) and adds the cooktop-specific capabilities +for the burner half. +""" +from ..capabilities import common, cooktop, ignored, oven +from ._base import DeviceRegistry, _build + +REGISTRY = DeviceRegistry( + name='range', + capabilities=_build([ + *ignored.IGNORED, + *common.UNIVERSAL, + *common.POWER, + oven.OVEN_CAVITY, + oven.OVEN_SETPOINT, + oven.OVEN_MODE, + oven.OVEN_OPERATIONAL_STATE, + oven.OVEN_DOOR, + oven.OVEN_CONNECTED, + oven.OVEN_SPEC, + cooktop.COOKTOP_STATUS, + cooktop.COOKTOP_SPEC, + cooktop.COOKTOP_SAFETY, + ]), +) diff --git a/custom_components/localthings/registry/capabilities/cooktop.py b/custom_components/localthings/registry/capabilities/cooktop.py new file mode 100644 index 0000000..a665c20 --- /dev/null +++ b/custom_components/localthings/registry/capabilities/cooktop.py @@ -0,0 +1,124 @@ +"""Capabilities for the cooktop half of range/combo appliances (issue #44, +model TP1X_DA-KS-RANGE-0102X). + +Unlike the rest of the OCF surface, these hrefs use plain camelCase field +names (no `x.com.samsung.da.` prefix) -- `/cooktop/status/vs/0` already +looks like a vendor resource migrated onto OCF-standard-shaped field naming. + +`/cooktop/status/vs/0` carries every burner's live state in one `burnerList` +array (indexed by `burnerNumber`, not by a separate href per burner like +fridge ice makers), so per-burner entities are hardcoded up to MAX_BURNERS +and gated by exists_fn against whichever indices the device actually +reports -- harmless over-declaration, per common.py's UNIVERSAL note, since +an index absent from burnerList just never binds. + +Write surfaces here are unproven (no live device to verify against, same +caveat as oven.py's RMW writes) -- power level uses the same read-modify- +write pattern already proven safe elsewhere in this codebase (oven setpoint, +icemaker toggles). +""" +from ..capability import Capability +from ..entities import BinarySensorDesc, SelectDesc, SensorDesc + +# Observed as high as 4 (this issue's dump); user-reported hardware with 5 +# burners exists. Kept a little above both since exists_fn gates unused +# slots out -- see module docstring. +MAX_BURNERS = 6 + + +def _burner(burner_list, i): + for b in (burner_list or []): + if b.get('burnerNumber') == i: + return b + return None + + +def _burner_exists(i): + return lambda rep, resources: _burner(rep.get('burnerList'), i) is not None + + +def _burner_field_fn(i, field): + return lambda burner_list: (_burner(burner_list, i) or {}).get(field) + + +def _burner_hot_surface_fn(i): + get_state = _burner_field_fn(i, 'hotSurfaceState') + return lambda burner_list: get_state(burner_list) not in (None, 'normal') + + +def _power_level_options(resources): + spec = resources.get('/cooktop/spec/vs/0') or {} + return list(spec.get('supportedPowerLevelList') or []) + + +def _burner_power_level_write(i): + def write(p, rep, href=None): + burner_list = rep.get('burnerList') + if not burner_list: + return None + new_list = [] + found = False + for b in burner_list: + if b.get('burnerNumber') == i: + b = dict(b) + b['powerLevel'] = p + found = True + new_list.append(b) + if not found: + return None + return ['cooktop', 'status', 'vs', '0'], {'burnerList': new_list} + return write + + +def _burner_entities(i): + exists = _burner_exists(i) + n = i + 1 + return ( + SelectDesc(key=f'burner_{i}_power_level', field='burnerList', + name=f'Burner {n} power level', icon='mdi:knob', + options=_power_level_options, + exists_fn=exists, + value_fn=_burner_field_fn(i, 'powerLevel'), + write_fn=_burner_power_level_write(i)), + SensorDesc(key=f'burner_{i}_state', field='burnerList', + name=f'Burner {n} state', icon='mdi:stove', + exists_fn=exists, + value_fn=_burner_field_fn(i, 'operationState')), + BinarySensorDesc(key=f'burner_{i}_hot_surface', field='burnerList', + name=f'Burner {n} hot surface', device_class='heat', + exists_fn=exists, + value_fn=_burner_hot_surface_fn(i)), + ) + + +COOKTOP_STATUS = Capability( + href='/cooktop/status/vs/0', + poll_tier='hot', + entities=( + SensorDesc(key='cooktop_state', field='operationState', + name='Cooktop state', icon='mdi:pot-steam'), + *[e for i in range(MAX_BURNERS) for e in _burner_entities(i)], + ), +) + +# Static burner-count/power-level-list metadata, read directly by +# COOKTOP_STATUS's power-level select (options=_power_level_options) rather +# than exposed through its own entity -- same "informs another capability, +# no entity of its own" pattern as /wm/editcourse/vs/0 (ignored.py). +COOKTOP_SPEC = Capability(href='/cooktop/spec/vs/0') + +# settingTime (seconds) is the hot-surface auto-shutoff timer's configured +# duration (1200s = 20 min in issue #44's dump); state on/off is whether the +# feature itself is enabled -- not a live "surface is hot right now" alert +# (that's COOKTOP_STATUS's per-burner hot_surface). No write contract +# verified, so read-only for now. +COOKTOP_SAFETY = Capability( + href='/cooktop/settings/status/vs/0', + poll_tier='warm', + entities=( + BinarySensorDesc(key='cooktop_safety_shutoff_enabled', field='safetyAlert', + name='Hot surface auto-shutoff enabled', + entity_category='config', + value_fn=lambda v: (v or {}).get('state') == 'on'), + ), +) diff --git a/custom_components/localthings/registry/capabilities/oven.py b/custom_components/localthings/registry/capabilities/oven.py index 764d66c..7d66b8b 100644 --- a/custom_components/localthings/registry/capabilities/oven.py +++ b/custom_components/localthings/registry/capabilities/oven.py @@ -314,6 +314,13 @@ OVEN_CONNECTED = Capability( ), ) +# Static cavity capability metadata (count/type/supported features) -- no +# per-cavity data varies at runtime on any dump seen so far (issue #44's +# range: single cavity, no supportedFeatureList entries). Bound with no +# entities purely for coverage; revisit if a multi-cavity dump surfaces +# fields worth exposing. +OVEN_SPEC = Capability(href='/oven/spec/vs/0') + OVEN_MODE = Capability( href='/mode/vs/0', poll_tier='warm', diff --git a/tests/fixtures/golden/range.json b/tests/fixtures/golden/range.json new file mode 100644 index 0000000..c4721de --- /dev/null +++ b/tests/fixtures/golden/range.json @@ -0,0 +1,39 @@ +{ + "state_keys": [ + "alarm_code", + "burner_0_hot_surface", + "burner_0_power_level", + "burner_0_state", + "burner_1_hot_surface", + "burner_1_power_level", + "burner_1_state", + "burner_2_hot_surface", + "burner_2_power_level", + "burner_2_state", + "burner_3_hot_surface", + "burner_3_power_level", + "burner_3_state", + "child_lock", + "cloud_connected", + "cook_time", + "cooktop_safety_shutoff_enabled", + "cooktop_state", + "current_temp_c", + "cycle_active", + "door_open", + "fast_preheat", + "finish_time", + "firmware_update", + "lamp", + "machine_state", + "natural_steam", + "operation_time_minutes", + "oven_mode", + "oven_setpoint", + "oven_state", + "power_switch", + "progress_percentage", + "remote_control", + "sound" + ] +} diff --git a/tests/fixtures/range_device.json b/tests/fixtures/range_device.json new file mode 100644 index 0000000..5482403 --- /dev/null +++ b/tests/fixtures/range_device.json @@ -0,0 +1,466 @@ +{ + "meta": { + "model": "TP1X_DA-KS-RANGE-0102X (NSI6DG9100SRAA)", + "device_type": "range", + "source": "issue #44 diagnostics (scrubbed)", + "note": "Range/cooktop-oven combo, first dump of this device type. 4 burners reported (numberOfBurners=4)." + }, + "device0": [ + { + "rt": [ + "x.com.samsung.devcol", + "oic.wk.col" + ], + "if": [ + "oic.if.baseline", + "oic.if.ll", + "oic.if.b" + ] + }, + { + "href": "/otninformation/vs/0", + "rep": { + "x.com.samsung.da.target": "", + "x.com.samsung.da.newVersionAvailable": "false", + "flashingProgress": "", + "otnStatus": "None", + "otnList": [ + { + "type": "WIFI", + "modelId": "AKS-WW-TP1-23-OVEN-3", + "versions": [ + "40241114" + ], + "visVersion": "241114" + }, + { + "type": "Micom", + "modelId": "07174047694140477041", + "versions": [ + "24052400", + "24012300" + ], + "visVersion": "240524" + }, + { + "type": "Micom", + "modelId": "071760141141FFFFFFFF", + "versions": [ + "24010500", + "FFFFFFFF" + ], + "visVersion": "240105" + }, + { + "type": "Micom", + "modelId": "071740476841FFFFFFFF", + "versions": [ + "23121600", + "FFFFFFFF" + ], + "visVersion": "231216" + }, + { + "type": "Micom", + "modelId": "07174047484140474941", + "versions": [ + "24020500", + "24012600" + ], + "visVersion": "240205" + } + ] + } + }, + { + "href": "/connected/vs/0", + "rep": { + "x.com.samsung.da.connected": "On" + } + }, + { + "href": "/doors/vs/0", + "rep": { + "rt": [ + "x.com.samsung.da.doors" + ], + "if": [ + "oic.if.baseline", + "oic.if.s" + ], + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "Door", + "x.com.samsung.da.openState": "Close", + "x.com.samsung.da.lock": "Unlock" + } + ] + } + }, + { + "href": "/information/vs/0", + "rep": { + "x.com.samsung.da.modelNum": "TP1X_DA-KS-RANGE-0102X|40476941|5001011E041811010A00000000000000", + "x.com.samsung.da.description": "NI9100D-/AA0", + "x.com.samsung.da.serialNum": "**REDACTED**", + "x.com.samsung.da.otnDUID": "**REDACTED**", + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "Version", + "x.com.samsung.da.type": "Software", + "x.com.samsung.da.number": "24111400", + "x.com.samsung.da.newVersionAvailable": "0" + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "Version", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.number": "04769A24052400, 04770A24012300", + "x.com.samsung.da.newVersionAvailable": "0" + }, + { + "x.com.samsung.da.id": "2", + "x.com.samsung.da.description": "Version", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.number": "01411A24010500", + "x.com.samsung.da.newVersionAvailable": "0" + }, + { + "x.com.samsung.da.id": "3", + "x.com.samsung.da.description": "Version", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.number": "04768A23121600", + "x.com.samsung.da.newVersionAvailable": "0" + }, + { + "x.com.samsung.da.id": "4", + "x.com.samsung.da.description": "Version", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.number": "04748A24020500, 04749A24012600", + "x.com.samsung.da.newVersionAvailable": "0" + } + ], + "x.com.samsung.da.diagProtocolType": "BLE_OCF", + "x.com.samsung.da.diagLogType": [ + "errCode", + "dump" + ], + "x.com.samsung.da.diagDumpType": "file", + "x.com.samsung.da.diagEndPoint": "SSM", + "x.com.samsung.da.diagMnid": "0AJT", + "x.com.samsung.da.diagSetupid": "KR3", + "x.com.samsung.da.diagMinVersion": "3.0", + "x.com.samsung.da.diagTsId": "DA01" + } + }, + { + "href": "/mode/vs/0", + "rep": { + "rt": [ + "x.com.samsung.da.mode" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ], + "x.com.samsung.da.supportedModes": [ + "Bake", + "Broil", + "KeepWarm", + "BreadProof", + "SelfClean", + "SteamClean" + ], + "x.com.samsung.da.modes": [ + "NoOperation" + ], + "x.com.samsung.da.modeStateList": [ + { + "mode": "Bake", + "state": "Fixed" + }, + { + "mode": "Broil", + "state": "Show" + }, + { + "mode": "KeepWarm", + "state": "Show" + }, + { + "mode": "BreadProof", + "state": "Show" + }, + { + "mode": "SelfClean", + "state": "Fixed" + }, + { + "mode": "SteamClean", + "state": "Fixed" + } + ], + "x.com.samsung.da.options": [ + "DeviceType_NI9100D-/AA0", + "SettingPossible_5", + "TimeAutoSync_On", + "UpperLamp_On", + "TimeSystem_12", + "Sound_On", + "AdjustingTemp_0", + "Sabbath_Off", + "EnergySaving_On" + ], + "x.com.samsung.da.defaultMode": "Bake", + "x.com.samsung.da.modeSpec": "[{\"mode\":\"Bake\",\"version\":\"0101\",\"default\":\"Default\",\"control\":\"Start&Setting\",\"cavity\":\"Single\",\"tempMinC\":\"80\",\"tempMaxC\":\"285\",\"tempDefaultC\":\"175\",\"tempListLengthC\":\"0\",\"tempMinF\":\"175\",\"tempMaxF\":\"550\",\"tempDefaultF\":\"350\",\"tempListLengthF\":\"0\",\"timeMin\":\"00:01:00\",\"timeMax\":\"09:59:00\",\"timeDefault\":\"01:00:00\",\"probeMinC\":\"40\",\"probeMaxC\":\"90\",\"probeDefaultC\":\"65\",\"probeMinF\":\"100\",\"probeMaxF\":\"200\",\"probeDefaultF\":\"150\",\"powerDefault\":\"NotSupported\",\"powerListLength\":\"0\",\"tempIntervalC\":\"5\",\"tempIntervalF\":\"5\",\"probeIntervalC\":\"1\",\"probeIntervalF\":\"1\"},{\"mode\":\"Broil\",\"version\":\"0101\",\"default\":\"Normal\",\"control\":\"Setting\",\"cavity\":\"Single\",\"tempMinC\":\"61442\",\"tempMaxC\":\"61441\",\"tempDefaultC\":\"61441\",\"tempListLengthC\":\"2\",\"tempListDataC\":[\"61441\",\"61442\"],\"tempMinF\":\"61442\",\"tempMaxF\":\"61441\",\"tempDefaultF\":\"61441\",\"tempListLengthF\":\"2\",\"tempListDataF\":[\"61441\",\"61442\"],\"timeMin\":\"NotSupported\",\"timeMax\":\"NotSupported\",\"timeDefault\":\"NotSupported\",\"probeMinC\":\"NotSupported\",\"probeMaxC\":\"NotSupported\",\"probeDefaultC\":\"NotSupported\",\"probeMinF\":\"NotSupported\",\"probeMaxF\":\"NotSupported\",\"probeDefaultF\":\"NotSupported\",\"powerDefault\":\"NotSupported\",\"powerListLength\":\"0\",\"tempIntervalC\":\"NotSupported\",\"tempIntervalF\":\"NotSupported\",\"probeIntervalC\":\"NotSupported\",\"probeIntervalF\":\"NotSupported\"},{\"mode\":\"KeepWarm\",\"version\":\"0101\",\"default\":\"Normal\",\"control\":\"Setting\",\"cavity\":\"Single\",\"tempMinC\":\"80\",\"tempMaxC\":\"80\",\"tempDefaultC\":\"80\",\"tempListLengthC\":\"1\",\"tempListDataC\":[\"80\"],\"tempMinF\":\"175\",\"tempMaxF\":\"175\",\"tempDefaultF\":\"175\",\"tempListLengthF\":\"1\",\"tempListDataF\":[\"175\"],\"timeMin\":\"NotSupported\",\"timeMax\":\"NotSupported\",\"timeDefault\":\"NotSupported\",\"probeMinC\":\"NotSupported\",\"probeMaxC\":\"NotSupported\",\"probeDefaultC\":\"NotSupported\",\"probeMinF\":\"NotSupported\",\"probeMaxF\":\"NotSupported\",\"probeDefaultF\":\"NotSupported\",\"powerDefault\":\"NotSupported\",\"powerListLength\":\"0\",\"tempIntervalC\":\"NotSupported\",\"tempIntervalF\":\"NotSupported\",\"probeIntervalC\":\"NotSupported\",\"probeIntervalF\":\"NotSupported\"},{\"mode\":\"BreadProof\",\"version\":\"0101\",\"default\":\"Normal\",\"control\":\"Setting\",\"cavity\":\"Single\",\"tempMinC\":\"35\",\"tempMaxC\":\"35\",\"tempDefaultC\":\"35\",\"tempListLengthC\":\"1\",\"tempListDataC\":[\"35\"],\"tempMinF\":\"95\",\"tempMaxF\":\"95\",\"tempDefaultF\":\"95\",\"tempListLengthF\":\"1\",\"tempListDataF\":[\"95\"],\"timeMin\":\"NotSupported\",\"timeMax\":\"NotSupported\",\"timeDefault\":\"NotSupported\",\"probeMinC\":\"NotSupported\",\"probeMaxC\":\"NotSupported\",\"probeDefaultC\":\"NotSupported\",\"probeMinF\":\"NotSupported\",\"probeMaxF\":\"NotSupported\",\"probeDefaultF\":\"NotSupported\",\"powerDefault\":\"NotSupported\",\"powerListLength\":\"0\",\"tempIntervalC\":\"NotSupported\",\"tempIntervalF\":\"NotSupported\",\"probeIntervalC\":\"NotSupported\",\"probeIntervalF\":\"NotSupported\"},{\"mode\":\"SelfClean\",\"version\":\"0101\",\"default\":\"Normal\",\"control\":\"NotSupported\",\"cavity\":\"Single\",\"tempMinC\":\"NotSupported\",\"tempMaxC\":\"NotSupported\",\"tempDefaultC\":\"NotSupported\",\"tempListLengthC\":\"0\",\"tempMinF\":\"NotSupported\",\"tempMaxF\":\"NotSupported\",\"tempDefaultF\":\"NotSupported\",\"tempListLengthF\":\"0\",\"timeMin\":\"NotSupported\",\"timeMax\":\"NotSupported\",\"timeDefault\":\"NotSupported\",\"probeMinC\":\"NotSupported\",\"probeMaxC\":\"NotSupported\",\"probeDefaultC\":\"NotSupported\",\"probeMinF\":\"NotSupported\",\"probeMaxF\":\"NotSupported\",\"probeDefaultF\":\"NotSupported\",\"powerDefault\":\"NotSupported\",\"powerListLength\":\"0\",\"tempIntervalC\":\"NotSupported\",\"tempIntervalF\":\"NotSupported\",\"probeIntervalC\":\"NotSupported\",\"probeIntervalF\":\"NotSupported\"},{\"mode\":\"SteamClean\",\"version\":\"0101\",\"default\":\"Normal\",\"control\":\"NotSupported\",\"cavity\":\"Single\",\"tempMinC\":\"NotSupported\",\"tempMaxC\":\"NotSupported\",\"tempDefaultC\":\"NotSupported\",\"tempListLengthC\":\"0\",\"tempMinF\":\"NotSupported\",\"tempMaxF\":\"NotSupported\",\"tempDefaultF\":\"NotSupported\",\"tempListLengthF\":\"0\",\"timeMin\":\"NotSupported\",\"timeMax\":\"NotSupported\",\"timeDefault\":\"NotSupported\",\"probeMinC\":\"NotSupported\",\"probeMaxC\":\"NotSupported\",\"probeDefaultC\":\"NotSupported\",\"probeMinF\":\"NotSupported\",\"probeMaxF\":\"NotSupported\",\"probeDefaultF\":\"NotSupported\",\"powerDefault\":\"NotSupported\",\"powerListLength\":\"0\",\"tempIntervalC\":\"NotSupported\",\"tempIntervalF\":\"NotSupported\",\"probeIntervalC\":\"NotSupported\",\"probeIntervalF\":\"NotSupported\"}]" + } + }, + { + "href": "/operational/state/vs/0", + "rep": { + "rt": [ + "x.com.samsung.da.operation" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ], + "x.com.samsung.da.state": "Ready", + "causeSource.state": "SETB_Ready", + "x.com.samsung.da.operationTime": "00:00:00", + "x.com.samsung.da.remainingTime": "00:00:00", + "x.com.samsung.da.progressPercentage": "1" + } + }, + { + "href": "/alarms/vs/0", + "rep": { + "rt": [ + "x.com.samsung.da.alarms" + ], + "if": [ + "oic.if.baseline", + "oic.if.s" + ], + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "Alarm", + "x.com.samsung.da.alarmType": "Device", + "x.com.samsung.da.code": "OV_E_OFF", + "x.com.samsung.da.triggeredTime": "2026-07-22T23:55:41" + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "Alarm", + "x.com.samsung.da.alarmType": "Device", + "x.com.samsung.da.code": "CT_E_OFF", + "x.com.samsung.da.triggeredTime": "2026-07-22T23:55:41" + } + ] + } + }, + { + "href": "/oven/vs/0", + "rep": { + "rt": [ + "x.com.samsung.da.oven" + ], + "if": [ + "oic.if.baseline", + "oic.if.s" + ], + "x.com.samsung.da.state": "Ready" + } + }, + { + "href": "/temperatures/vs/0", + "rep": { + "rt": [ + "x.com.samsung.da.temperatures" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ], + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "Temperature", + "x.com.samsung.da.desired": "0", + "x.com.samsung.da.current": "175", + "x.com.samsung.da.increment": "5", + "x.com.samsung.da.unit": "Fahrenheit" + } + ] + } + }, + { + "href": "/timezone/vs/0", + "rep": { + "timezoneid": "America/Chicago", + "offset": "-05:00", + "DST": "ON" + } + }, + { + "href": "/oven/spec/vs/0", + "rep": { + "cavityInfo": { + "numberOfCavity": 1, + "type": "single", + "serviceMainCavity": "single", + "cavityList": [ + { + "name": "single", + "flexSupport": false, + "single": { + "resourceId": 0, + "supportedFeatureList": [ + "" + ] + } + } + ] + } + } + }, + { + "href": "/kidslock/vs/0", + "rep": { + "x.com.samsung.da.kidsLock": "Ready" + } + }, + { + "href": "/power/vs/0", + "rep": { + "x.com.samsung.da.power": "On" + } + }, + { + "href": "/remotectrl/vs/0", + "rep": { + "x.com.samsung.da.remoteControlEnabled": "false" + } + }, + { + "href": "/cooktop/settings/status/vs/0", + "rep": { + "safetyAlert": { + "state": "on", + "settingTime": 1200 + } + } + }, + { + "href": "/cooktop/spec/vs/0", + "rep": { + "supportedFeatureList": [ + "" + ], + "numberOfBurners": 4, + "supportedBurnerList": [ + 0, + 1, + 2, + 3 + ], + "supportedPowerLevelList": [ + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9", + "boost", + "0", + "simmer" + ], + "supportedRecipeProtocolVersion": 0 + } + }, + { + "href": "/cooktop/status/vs/0", + "rep": { + "power": "off", + "childLock": "off", + "operationState": "ready", + "additionalStateList": [ + "" + ], + "burnerList": [ + { + "burnerNumber": 0, + "mode": "normal", + "operationState": "ready", + "powerLevel": "0", + "hotSurfaceState": "normal", + "panDetection": true, + "timer": { + "operationState": "ready", + "cookingTime": 0, + "remainingTime": 0 + } + }, + { + "burnerNumber": 1, + "mode": "normal", + "operationState": "ready", + "powerLevel": "0", + "hotSurfaceState": "normal", + "panDetection": true, + "timer": { + "operationState": "ready", + "cookingTime": 0, + "remainingTime": 0 + } + }, + { + "burnerNumber": 2, + "mode": "normal", + "operationState": "ready", + "powerLevel": "0", + "hotSurfaceState": "normal", + "panDetection": true, + "timer": { + "operationState": "ready", + "cookingTime": 0, + "remainingTime": 0 + } + }, + { + "burnerNumber": 3, + "mode": "normal", + "operationState": "ready", + "powerLevel": "0", + "hotSurfaceState": "normal", + "panDetection": true, + "timer": { + "operationState": "ready", + "cookingTime": 0, + "remainingTime": 0 + } + } + ] + } + }, + { + "href": "/wirelessinfo/vs/0", + "rep": { + "macaddressWiFi": "**REDACTED**", + "macaddressBLE": "**REDACTED**" + } + }, + { + "href": "/quickcontrol/info/vs/0", + "rep": { + "supportedVersion": "1.0" + } + } + ] +} diff --git a/tests/test_golden_regression.py b/tests/test_golden_regression.py index 0dc0066..a351bf8 100644 --- a/tests/test_golden_regression.py +++ b/tests/test_golden_regression.py @@ -199,6 +199,23 @@ def test_registry_reproduces_golden_state_keys_for_tp1x_rac(): ) +def test_registry_reproduces_golden_state_keys_for_range(): + """Range/cooktop-oven combo (model TP1X_DA-KS-RANGE-0102X, issue #44) -- + reports no oneUiVersion; resolved via the '-RANGE-' modelNum token + fallback in for_device_by_model. Reuses the oven family's cavity/ + setpoint/mode/operational-state capabilities and adds cooktop.py's + per-burner capabilities for the 4 burners this dump reports.""" + from tests.conftest import _load_device + resources = _load_device('range') + golden = json.loads((GOLDEN / 'range.json').read_text()) + state_keys = _new_state_keys('range', resources) + assert set(state_keys) == set(golden['state_keys']), ( + f"state_keys mismatch:\n" + f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n" + f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}" + ) + + def test_resources_from_batch_preferred_over_flat(): from tests.conftest import _resources_from_dump dump = { diff --git a/tests/test_range_capabilities.py b/tests/test_range_capabilities.py new file mode 100644 index 0000000..e04733a --- /dev/null +++ b/tests/test_range_capabilities.py @@ -0,0 +1,84 @@ +"""Tests for range/cooktop-oven combo support (issue #44).""" +from custom_components.localthings.registry.adapter import flatten +from custom_components.localthings.registry.by_type import for_device_by_model +from custom_components.localthings.registry.capabilities import cooktop +from custom_components.localthings.registry.discovery import discover + +from tests.conftest import _load_device + + +def _range(): + resources = _load_device('range') + info = resources['/information/vs/0'] + reg = for_device_by_model( + info['x.com.samsung.da.modelNum'], info['x.com.samsung.da.description'], + ) + return reg, resources + + +def _state(): + reg, resources = _range() + bound = discover(resources, reg.capabilities, reg.pattern_capabilities) + return flatten(bound, resources) + + +def test_range_model_resolves_to_range_registry(): + reg, _ = _range() + assert reg is not None and reg.name == 'range' + + +def test_no_unbound_hrefs(): + """Every resource in the issue #44 dump binds or is ignored -- clears the + coverage-gap repair.""" + reg, resources = _range() + unbound = [] + discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append) + assert unbound == [] + + +def test_expected_entities_present(): + state = _state() + for key in ( + 'power_switch', 'oven_setpoint', 'current_temp_c', 'oven_mode', + 'machine_state', 'door_open', 'cloud_connected', 'cooktop_state', + 'cooktop_safety_shutoff_enabled', + 'burner_0_power_level', 'burner_0_state', 'burner_0_hot_surface', + 'burner_3_power_level', 'burner_3_state', 'burner_3_hot_surface', + ): + assert key in state, key + + +def test_unreported_burners_gated_out(): + """Dump reports numberOfBurners=4 (indices 0-3) -- burner slots 4/5 of + MAX_BURNERS must not appear as entities.""" + state = _state() + for key in ('burner_4_power_level', 'burner_5_power_level'): + assert key not in state, key + + +def test_burner_power_level_write_is_read_modify_write(): + desc = next(e for e in cooktop.COOKTOP_STATUS.entities + if e.key == 'burner_1_power_level') + rep = {'burnerList': [ + {'burnerNumber': 0, 'powerLevel': '3'}, + {'burnerNumber': 1, 'powerLevel': '0'}, + ]} + path, body = desc.write_fn('boost', rep) + assert path == ['cooktop', 'status', 'vs', '0'] + burners = {b['burnerNumber']: b['powerLevel'] for b in body['burnerList']} + assert burners[1] == 'boost' + assert burners[0] == '3' # sibling burner untouched + + +def test_burner_power_level_write_rejects_missing_burner(): + desc = next(e for e in cooktop.COOKTOP_STATUS.entities + if e.key == 'burner_2_power_level') + rep = {'burnerList': [{'burnerNumber': 0, 'powerLevel': '0'}]} + assert desc.write_fn('5', rep) is None + + +def test_burner_hot_surface_true_when_not_normal(): + desc = next(e for e in cooktop.COOKTOP_STATUS.entities + if e.key == 'burner_0_hot_surface') + assert desc.value_fn([{'burnerNumber': 0, 'hotSurfaceState': 'hot'}]) is True + assert desc.value_fn([{'burnerNumber': 0, 'hotSurfaceState': 'normal'}]) is False From aec3c5e46092eff87c46ff42532831357aeb532c Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Thu, 23 Jul 2026 02:04:33 +0000 Subject: [PATCH 4/6] Rename cooktop.py capabilities module to range.py to avoid PR #23 collision PR #23 independently adds registry/capabilities/cooktop.py for an unrelated standalone-cooktop product (NA9300K-class, burner state encoded in /mode/vs/0's options array) -- different hardware and a different OCF surface than issue #44's oven+cooktop combo range, but the same file path. Rename ours to range.py to keep both mergeable. --- custom_components/localthings/registry/by_type/range.py | 9 +++++---- .../registry/capabilities/{cooktop.py => range.py} | 6 ++++++ tests/test_range_capabilities.py | 8 ++++---- 3 files changed, 15 insertions(+), 8 deletions(-) rename custom_components/localthings/registry/capabilities/{cooktop.py => range.py} (93%) diff --git a/custom_components/localthings/registry/by_type/range.py b/custom_components/localthings/registry/by_type/range.py index fe50bcf..7cc891f 100644 --- a/custom_components/localthings/registry/by_type/range.py +++ b/custom_components/localthings/registry/by_type/range.py @@ -5,7 +5,8 @@ connected capabilities wholesale (a range's oven half is the same OCF surface as a standalone oven) and adds the cooktop-specific capabilities for the burner half. """ -from ..capabilities import common, cooktop, ignored, oven +from ..capabilities import common, ignored, oven +from ..capabilities import range as range_caps from ._base import DeviceRegistry, _build REGISTRY = DeviceRegistry( @@ -21,8 +22,8 @@ REGISTRY = DeviceRegistry( oven.OVEN_DOOR, oven.OVEN_CONNECTED, oven.OVEN_SPEC, - cooktop.COOKTOP_STATUS, - cooktop.COOKTOP_SPEC, - cooktop.COOKTOP_SAFETY, + range_caps.COOKTOP_STATUS, + range_caps.COOKTOP_SPEC, + range_caps.COOKTOP_SAFETY, ]), ) diff --git a/custom_components/localthings/registry/capabilities/cooktop.py b/custom_components/localthings/registry/capabilities/range.py similarity index 93% rename from custom_components/localthings/registry/capabilities/cooktop.py rename to custom_components/localthings/registry/capabilities/range.py index a665c20..7c1e195 100644 --- a/custom_components/localthings/registry/capabilities/cooktop.py +++ b/custom_components/localthings/registry/capabilities/range.py @@ -1,6 +1,12 @@ """Capabilities for the cooktop half of range/combo appliances (issue #44, model TP1X_DA-KS-RANGE-0102X). +Not to be confused with PR #23's registry/capabilities/cooktop.py, which +covers an unrelated standalone-cooktop product (NA9300K-class) that encodes +burner state as strings inside /mode/vs/0's options array instead of the +structured /cooktop/status/vs/0 resource this module reads -- two different +OCF surfaces that happen to share the English word "cooktop". + Unlike the rest of the OCF surface, these hrefs use plain camelCase field names (no `x.com.samsung.da.` prefix) -- `/cooktop/status/vs/0` already looks like a vendor resource migrated onto OCF-standard-shaped field naming. diff --git a/tests/test_range_capabilities.py b/tests/test_range_capabilities.py index e04733a..cc5de15 100644 --- a/tests/test_range_capabilities.py +++ b/tests/test_range_capabilities.py @@ -1,7 +1,7 @@ """Tests for range/cooktop-oven combo support (issue #44).""" from custom_components.localthings.registry.adapter import flatten from custom_components.localthings.registry.by_type import for_device_by_model -from custom_components.localthings.registry.capabilities import cooktop +from custom_components.localthings.registry.capabilities import range as range_caps from custom_components.localthings.registry.discovery import discover from tests.conftest import _load_device @@ -57,7 +57,7 @@ def test_unreported_burners_gated_out(): def test_burner_power_level_write_is_read_modify_write(): - desc = next(e for e in cooktop.COOKTOP_STATUS.entities + desc = next(e for e in range_caps.COOKTOP_STATUS.entities if e.key == 'burner_1_power_level') rep = {'burnerList': [ {'burnerNumber': 0, 'powerLevel': '3'}, @@ -71,14 +71,14 @@ def test_burner_power_level_write_is_read_modify_write(): def test_burner_power_level_write_rejects_missing_burner(): - desc = next(e for e in cooktop.COOKTOP_STATUS.entities + desc = next(e for e in range_caps.COOKTOP_STATUS.entities if e.key == 'burner_2_power_level') rep = {'burnerList': [{'burnerNumber': 0, 'powerLevel': '0'}]} assert desc.write_fn('5', rep) is None def test_burner_hot_surface_true_when_not_normal(): - desc = next(e for e in cooktop.COOKTOP_STATUS.entities + desc = next(e for e in range_caps.COOKTOP_STATUS.entities if e.key == 'burner_0_hot_surface') assert desc.value_fn([{'burnerNumber': 0, 'hotSurfaceState': 'hot'}]) is True assert desc.value_fn([{'burnerNumber': 0, 'hotSurfaceState': 'normal'}]) is False From 8ca2c3cb160d66480d68fd2ed6019df5a408915d Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Thu, 23 Jul 2026 02:23:01 +0000 Subject: [PATCH 5/6] Address opus review: Fahrenheit setpoint bounds, OVEN_SPEC coverage, docstring - NumberDesc gains native_min_fn/native_max_fn/step_fn hooks (mirroring the existing unit_fn pattern) so an entity's slider bounds can track the live rep instead of staying pinned to whatever unit the descriptor was written against. Oven setpoint was hardcoded to Celsius bounds (30-270), which silently capped issue #44's Fahrenheit range at 270F -- below a normal 350F bake temp. Verified Fahrenheit bounds (175-550, step 5) come from that dump's /mode/vs/0 Bake modeSpec. - Wire oven.OVEN_SPEC into the oven registry, not just range -- it was only reachable from range before, so a standalone oven reporting /oven/spec/vs/0 would have false-tripped the coverage-gap repair. - Fix a docstring in test_golden_regression.py left over from the cooktop.py -> range.py rename. --- custom_components/localthings/number.py | 15 +++++++ .../localthings/registry/by_type/oven.py | 1 + .../localthings/registry/capabilities/oven.py | 33 ++++++++++++--- .../localthings/registry/entities.py | 7 ++++ tests/test_golden_regression.py | 2 +- tests/test_oven_capabilities.py | 40 +++++++++++++++++++ 6 files changed, 92 insertions(+), 6 deletions(-) diff --git a/custom_components/localthings/number.py b/custom_components/localthings/number.py index 2d8358b..ab04589 100644 --- a/custom_components/localthings/number.py +++ b/custom_components/localthings/number.py @@ -58,6 +58,9 @@ class LocalThingsNumber(LocalThingsEntity, NumberEntity): @property def native_min_value(self) -> float: + desc: NumberDesc = self._bound.desc + if desc.native_min_fn is not None: + return desc.native_min_fn(self.coordinator.resource(self._bound.href)) r = self._range_from_resource() if r is not None: return float(r[0]) @@ -67,6 +70,9 @@ class LocalThingsNumber(LocalThingsEntity, NumberEntity): @property def native_max_value(self) -> float: + desc: NumberDesc = self._bound.desc + if desc.native_max_fn is not None: + return desc.native_max_fn(self.coordinator.resource(self._bound.href)) r = self._range_from_resource() if r is not None: return float(r[1]) @@ -74,6 +80,15 @@ class LocalThingsNumber(LocalThingsEntity, NumberEntity): return self._attr_native_max_value return super().native_max_value + @property + def native_step(self) -> float: + desc: NumberDesc = self._bound.desc + if desc.step_fn is not None: + return desc.step_fn(self.coordinator.resource(self._bound.href)) + if hasattr(self, '_attr_native_step'): + return self._attr_native_step + return super().native_step + @property def native_value(self): return (self.coordinator.data or {}).get(self._state_key) diff --git a/custom_components/localthings/registry/by_type/oven.py b/custom_components/localthings/registry/by_type/oven.py index 2ae3341..c4028ce 100644 --- a/custom_components/localthings/registry/by_type/oven.py +++ b/custom_components/localthings/registry/by_type/oven.py @@ -14,5 +14,6 @@ REGISTRY = DeviceRegistry( oven.OVEN_OPERATIONAL_STATE, oven.OVEN_DOOR, oven.OVEN_CONNECTED, + oven.OVEN_SPEC, ]), ) diff --git a/custom_components/localthings/registry/capabilities/oven.py b/custom_components/localthings/registry/capabilities/oven.py index 7d66b8b..2eadfc4 100644 --- a/custom_components/localthings/registry/capabilities/oven.py +++ b/custom_components/localthings/registry/capabilities/oven.py @@ -39,6 +39,15 @@ SETPOINT_MIN_C = 30 SETPOINT_MAX_C = 270 SETPOINT_STEP_C = 5 +# Verified against issue #44's range dump (NSI6DG9100SRAA, unit reported as +# "Fahrenheit" on /temperatures/vs/0): Bake mode's modeSpec on /mode/vs/0 +# reports tempMinF/tempMaxF/tempIntervalF = 175/550/5. Kept as a separate +# constant set rather than converted from the Celsius bounds above, which +# are themselves unverified (no live dump; see module docstring). +SETPOINT_MIN_F = 175 +SETPOINT_MAX_F = 550 +SETPOINT_STEP_F = 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. @@ -129,8 +138,9 @@ def _oven_setpoint_write(p, rep, href=None): 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): + min_v, max_v, step_v = _setpoint_bounds(rep) + temp_i = int(round(temp / step_v) * step_v) + if not (min_v <= temp_i <= max_v): return None items = rep.get('x.com.samsung.da.items') if not items: @@ -263,14 +273,24 @@ def _oven_temp_unit(rep): same aggregate `/temperatures/vs/0` items[] resource type, which on fridge hardware carries a per-item `x.com.samsung.da.unit` field ('Celsius'/'Fahrenheit') that was previously hardcoded away (issue #7). - No live oven dump has surfaced a non-Celsius reading yet, so this keeps - the verified '°C' default when the field is absent, but reads it live - if a device ever reports otherwise.""" + Keeps the verified '°C' default when the field is absent (the original + NV7000BS-class dump this module was written against), but reads it live + -- issue #44's range dump is the first to report 'Fahrenheit' here.""" items = rep.get('x.com.samsung.da.items') or [] unit = items[0].get('x.com.samsung.da.unit') if items else None return normalize_temp_unit(unit, default='°C') +def _setpoint_bounds(rep): + """(min, max, step) for the live unit -- see the SETPOINT_*_C/_F + constants above for provenance. Bounds must track the unit shown by + unit_fn (both read the same live rep), or the HA slider's range would + silently mismatch its own displayed unit.""" + if _oven_temp_unit(rep) == '°F': + return SETPOINT_MIN_F, SETPOINT_MAX_F, SETPOINT_STEP_F + return SETPOINT_MIN_C, SETPOINT_MAX_C, SETPOINT_STEP_C + + OVEN_SETPOINT = Capability( href='/temperatures/vs/0', poll_tier='hot', @@ -280,6 +300,9 @@ OVEN_SETPOINT = Capability( name='Setpoint', device_class='temperature', unit_fn=_oven_temp_unit, native_min=float(SETPOINT_MIN_C), native_max=float(SETPOINT_MAX_C), step=float(SETPOINT_STEP_C), icon='mdi:thermometer-chevron-up', + native_min_fn=lambda rep: float(_setpoint_bounds(rep)[0]), + native_max_fn=lambda rep: float(_setpoint_bounds(rep)[1]), + step_fn=lambda rep: float(_setpoint_bounds(rep)[2]), value_fn=lambda items: _int( (items[0].get('x.com.samsung.da.desired') if items else None)), write_fn=_oven_setpoint_write), diff --git a/custom_components/localthings/registry/entities.py b/custom_components/localthings/registry/entities.py index e1b98fc..322f83d 100644 --- a/custom_components/localthings/registry/entities.py +++ b/custom_components/localthings/registry/entities.py @@ -85,6 +85,13 @@ class NumberDesc(SamsungEntityDescription): native_min: Optional[float] = None native_max: Optional[float] = None step: Optional[float] = None + # Override native_min/native_max/step from the live rep, when set -- + # same "static default, live override" shape as unit_fn, for resources + # whose sane bounds depend on a per-device value (e.g. a temperature + # setpoint reported in Celsius on one device, Fahrenheit on another). + native_min_fn: Optional[Callable[[dict], float]] = None + native_max_fn: Optional[Callable[[dict], float]] = None + step_fn: Optional[Callable[[dict], float]] = None range_field: Optional[str] = None # resource field containing [min, max] list write_fn: WriteFn = None diff --git a/tests/test_golden_regression.py b/tests/test_golden_regression.py index a351bf8..2edbe74 100644 --- a/tests/test_golden_regression.py +++ b/tests/test_golden_regression.py @@ -203,7 +203,7 @@ def test_registry_reproduces_golden_state_keys_for_range(): """Range/cooktop-oven combo (model TP1X_DA-KS-RANGE-0102X, issue #44) -- reports no oneUiVersion; resolved via the '-RANGE-' modelNum token fallback in for_device_by_model. Reuses the oven family's cavity/ - setpoint/mode/operational-state capabilities and adds cooktop.py's + setpoint/mode/operational-state capabilities and adds range.py's per-burner capabilities for the 4 burners this dump reports.""" from tests.conftest import _load_device resources = _load_device('range') diff --git a/tests/test_oven_capabilities.py b/tests/test_oven_capabilities.py index 8799fa0..aafad2b 100644 --- a/tests/test_oven_capabilities.py +++ b/tests/test_oven_capabilities.py @@ -53,6 +53,46 @@ def test_oven_setpoint_rejects_missing_items(): assert desc.write_fn(200, {}) is None +# --------------------------------------------------------------------------- +# OVEN_SETPOINT — Fahrenheit bounds (issue #44 range dump reports +# unit="Fahrenheit"; bounds/step must track the live unit, not stay pinned +# to the Celsius defaults) +# --------------------------------------------------------------------------- + +def _fahrenheit_rep(desired='0'): + return {'x.com.samsung.da.items': [{ + 'x.com.samsung.da.desired': desired, + 'x.com.samsung.da.unit': 'Fahrenheit', + }]} + + +def test_oven_setpoint_write_uses_fahrenheit_bounds(): + desc = oven.OVEN_SETPOINT.entities[0] + rep = _fahrenheit_rep() + # 350 is within F bounds (175-550) but above the C max (270) -- + # confirms the write path isn't silently still clamping to Celsius. + _, body = desc.write_fn(350, rep) + assert body['x.com.samsung.da.items'][0]['x.com.samsung.da.desired'] == '350' + + +def test_oven_setpoint_rejects_out_of_range_fahrenheit(): + desc = oven.OVEN_SETPOINT.entities[0] + rep = _fahrenheit_rep() + assert desc.write_fn(100, rep) is None # below F min (175) + assert desc.write_fn(600, rep) is None # above F max (550) + + +def test_oven_setpoint_native_bounds_track_live_unit(): + desc = oven.OVEN_SETPOINT.entities[0] + celsius_rep = {'x.com.samsung.da.items': [{'x.com.samsung.da.unit': 'Celsius'}]} + assert desc.native_min_fn(celsius_rep) == 30.0 + assert desc.native_max_fn(celsius_rep) == 270.0 + fahrenheit_rep = _fahrenheit_rep() + assert desc.native_min_fn(fahrenheit_rep) == 175.0 + assert desc.native_max_fn(fahrenheit_rep) == 550.0 + assert desc.step_fn(fahrenheit_rep) == 5.0 + + # --------------------------------------------------------------------------- # OVEN_MODE — SelectDesc with non-empty options # --------------------------------------------------------------------------- From 62d4ed673b4582d407799c28e21cd8d7cd3d5168 Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Thu, 23 Jul 2026 02:40:06 +0000 Subject: [PATCH 6/6] Bump version to 0.10.0 --- custom_components/localthings/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/localthings/manifest.json b/custom_components/localthings/manifest.json index dad4fc9..4342ff1 100644 --- a/custom_components/localthings/manifest.json +++ b/custom_components/localthings/manifest.json @@ -12,5 +12,5 @@ "pyOpenSSL>=23.0", "smartthings-local>=0.1.0" ], - "version": "0.9.0" + "version": "0.10.0" }