diff --git a/custom_components/localthings/coordinator.py b/custom_components/localthings/coordinator.py index 94dc1d9..2e22b9f 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, for_device_by_resources +from .registry.capabilities.common import remote_control_enabled from .registry.discovery import discover, BoundEntity from .registry import CAPABILITIES from .registry.adapter import flatten @@ -37,6 +38,12 @@ _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." +) + class _NoOpDescriptor: """StateCache requires a descriptor with an on_observation hook. This @@ -517,16 +524,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 not remote_control_enabled(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/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" } 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/__init__.py b/custom_components/localthings/registry/by_type/__init__.py index 102a734..37fc085 100644 --- a/custom_components/localthings/registry/by_type/__init__.py +++ b/custom_components/localthings/registry/by_type/__init__.py @@ -3,8 +3,8 @@ from typing import Optional from ._base import DeviceRegistry from . import ( - airconditioner, cooktop, dishwasher, dryer, oven, range_hood, refrigerator, - washer, + airconditioner, cooktop, dishwasher, dryer, oven, range as _range, + range_hood, refrigerator, washer, ) __all__ = [ @@ -21,6 +21,7 @@ _REGISTRY_BY_KEY: dict[str, DeviceRegistry] = { 'dryer': dryer.REGISTRY, 'oven': oven.REGISTRY, 'hood': range_hood.REGISTRY, + 'range': _range.REGISTRY, 'range_hood': range_hood.REGISTRY, 'refrigerator': refrigerator.REGISTRY, 'washer': washer.REGISTRY, @@ -115,6 +116,11 @@ def for_device_by_model(model_num: str, description: str) -> Optional[DeviceRegi key = 'cooktop' if key is None and model_identity.startswith('AHD-'): key = 'range_hood' + # 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/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/by_type/range.py b/custom_components/localthings/registry/by_type/range.py new file mode 100644 index 0000000..7cc891f --- /dev/null +++ b/custom_components/localthings/registry/by_type/range.py @@ -0,0 +1,29 @@ +"""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, ignored, oven +from ..capabilities import range as range_caps +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, + range_caps.COOKTOP_STATUS, + range_caps.COOKTOP_SPEC, + range_caps.COOKTOP_SAFETY, + ]), +) 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/custom_components/localthings/registry/capabilities/oven.py b/custom_components/localthings/registry/capabilities/oven.py index 764d66c..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), @@ -314,6 +337,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/custom_components/localthings/registry/capabilities/range.py b/custom_components/localthings/registry/capabilities/range.py new file mode 100644 index 0000000..7c1e195 --- /dev/null +++ b/custom_components/localthings/registry/capabilities/range.py @@ -0,0 +1,130 @@ +"""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. + +`/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/entities.py b/custom_components/localthings/registry/entities.py index 2e893ba..bde619b 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/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/localthings/test_coordinator.py b/tests/localthings/test_coordinator.py index 1cff426..bbe7e69 100644 --- a/tests/localthings/test_coordinator.py +++ b/tests/localthings/test_coordinator.py @@ -7,12 +7,16 @@ 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.registry.capabilities.common import ( + remote_control_enabled, +) from custom_components.localthings.observe import MODE_OBSERVE, MODE_POLL, PUSH_HEALTH_WINDOW_S from .conftest import ENTRY_DATA, MOCK_SERIAL @@ -659,3 +663,137 @@ 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 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_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_enabled(resources) is False + + def test_generic_href_true_is_enabled(self): + resources = {'/remotectrl/0': {'value': True}} + assert remote_control_enabled(resources) is True + + def test_generic_href_false_is_disabled(self): + resources = {'/remotectrl/0': {'value': False}} + 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_enabled(resources) is True + + def test_assumes_enabled_when_capability_absent(self): + assert remote_control_enabled({}) is True + + +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') 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 diff --git a/tests/test_golden_regression.py b/tests/test_golden_regression.py index 9585d3a..a4cca9b 100644 --- a/tests/test_golden_regression.py +++ b/tests/test_golden_regression.py @@ -227,6 +227,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 range.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_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 # --------------------------------------------------------------------------- diff --git a/tests/test_range_capabilities.py b/tests/test_range_capabilities.py new file mode 100644 index 0000000..cc5de15 --- /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 range as range_caps +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 range_caps.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 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 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