From 321f186b0edcc1c0211035ff7d7b9985832aa1d0 Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Thu, 23 Jul 2026 01:45:32 +0000 Subject: [PATCH 1/3] 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 2/3] 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 3/3] 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 # ---------------------------------------------------------------------------