Merge remote-tracking branch 'upstream/main' into contrib/cooktop-range-hood

# Conflicts:
#	custom_components/localthings/coordinator.py
#	custom_components/localthings/registry/by_type/__init__.py
This commit is contained in:
themaanda
2026-07-22 21:59:23 -05:00
17 changed files with 1055 additions and 10 deletions
+14 -2
View File
@@ -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)
+1 -1
View File
@@ -12,5 +12,5 @@
"pyOpenSSL>=23.0",
"smartthings-local>=0.1.0"
],
"version": "0.9.0"
"version": "0.10.0"
}
+15
View File
@@ -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)
@@ -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
@@ -14,5 +14,6 @@ REGISTRY = DeviceRegistry(
oven.OVEN_OPERATIONAL_STATE,
oven.OVEN_DOOR,
oven.OVEN_CONNECTED,
oven.OVEN_SPEC,
]),
)
@@ -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,
]),
)
@@ -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',
@@ -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',
@@ -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'),
),
)
@@ -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
+39
View File
@@ -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"
]
}
+466
View File
@@ -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"
}
}
]
}
+138
View File
@@ -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')
+8
View File
@@ -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
+17
View File
@@ -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 = {
+40
View File
@@ -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
# ---------------------------------------------------------------------------
+84
View File
@@ -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