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:
@@ -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)
|
||||
|
||||
@@ -12,5 +12,5 @@
|
||||
"pyOpenSSL>=23.0",
|
||||
"smartthings-local>=0.1.0"
|
||||
],
|
||||
"version": "0.9.0"
|
||||
"version": "0.10.0"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Vendored
+39
@@ -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"
|
||||
]
|
||||
}
|
||||
Vendored
+466
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user