Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed461d82cd | ||
|
|
62d4ed673b | ||
|
|
e74089a545 | ||
|
|
b2f3e501e5 | ||
|
|
8ca2c3cb16 | ||
|
|
aec3c5e460 | ||
|
|
321f186b0e | ||
|
|
2f6b7d54e3 | ||
|
|
6e5fee32b8 | ||
|
|
1e9437ef3c | ||
|
|
36f7aa0b8c | ||
|
|
10ab51c7f3 | ||
|
|
8bf9002eb7 | ||
|
|
fb0f24a175 | ||
|
|
5944d14bca |
@@ -22,6 +22,7 @@ from smartthings_local.ocf.state_cache import StateCache
|
||||
|
||||
from .registry.batch import parse_device0_batch
|
||||
from .registry.by_type import for_device, for_device_by_model
|
||||
from .registry.capabilities.common import remote_control_enabled
|
||||
from .registry.discovery import discover, BoundEntity
|
||||
from .registry import CAPABILITIES
|
||||
from .registry.adapter import flatten
|
||||
@@ -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
|
||||
@@ -515,16 +522,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)
|
||||
|
||||
@@ -8,7 +8,7 @@ from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.const import EntityCategory
|
||||
|
||||
from .registry.adapter import _key
|
||||
from .registry.discovery import BoundEntity
|
||||
from .registry.discovery import BoundEntity, _snake_to_title
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import LocalThingsCoordinator
|
||||
@@ -44,7 +44,7 @@ def _derive_name(state_key: str) -> str:
|
||||
instance number with a space: "door_cooler_open1" → "Door Cooler Open 1".
|
||||
"""
|
||||
name = re.sub(r'(\d+)$', lambda m: f' {m.group()}' if int(m.group()) > 0 else '', state_key)
|
||||
return name.replace('_', ' ').title().strip()
|
||||
return _snake_to_title(name).strip()
|
||||
|
||||
|
||||
class LocalThingsEntity(CoordinatorEntity[LocalThingsCoordinator]):
|
||||
@@ -57,7 +57,15 @@ class LocalThingsEntity(CoordinatorEntity[LocalThingsCoordinator]):
|
||||
self._bound = bound
|
||||
self._state_key = _key(bound)
|
||||
self._attr_unique_id = f"{DOMAIN}_{coordinator.device_serial}_{self._state_key}"
|
||||
self._attr_name = bound.desc.name if bound.desc.name is not None else _derive_name(self._state_key)
|
||||
if bound.desc.name is not None:
|
||||
self._attr_name = bound.desc.name
|
||||
elif bound.instance_name:
|
||||
# A device-given instance name (e.g. an ice maker's "Cubed
|
||||
# Ice") takes the place of the href-derived instance label,
|
||||
# keeping the same entity-specific suffix (issue #27).
|
||||
self._attr_name = f"{bound.instance_name} {_derive_name(bound.desc.key)}".strip()
|
||||
else:
|
||||
self._attr_name = _derive_name(self._state_key)
|
||||
self._attr_translation_key = bound.desc.translation_key
|
||||
self._attr_icon = bound.desc.icon
|
||||
raw_cat = bound.desc.entity_category
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -69,6 +69,7 @@ class ObserveManager:
|
||||
self.last_mode_change_wall = time.time()
|
||||
self._settle_until: dict[str, float] = {}
|
||||
self._settle_lock = threading.Lock()
|
||||
self._cache_lock = threading.Lock()
|
||||
self.subscribed_hrefs: set[str] = set()
|
||||
self._notified: set[str] = set()
|
||||
self._last_notify_ts: float | None = None
|
||||
@@ -92,11 +93,35 @@ class ObserveManager:
|
||||
return True
|
||||
|
||||
def apply(self, href: str, rep: dict, source: str) -> bool:
|
||||
"""Gate a StateCache.apply_rep call through the write-settle guard."""
|
||||
"""Gate a StateCache.apply_rep call through the write-settle guard.
|
||||
|
||||
Merges the incoming rep onto whatever's already cached for this
|
||||
href rather than handing it to StateCache.apply_rep verbatim --
|
||||
apply_rep does a full replace, and Samsung devices don't always
|
||||
repeat every field on every update (issue #27: a /mode/vs/0
|
||||
OBSERVE notify -- and, on at least one Bespoke fridge, even the
|
||||
/device/0 sweep entry for that href -- can carry just `modes`,
|
||||
omitting `supportedOptions`/`supportedModes` entirely). A full
|
||||
replace would silently wipe fields a select entity's
|
||||
exists_fn/options_field gates on the moment one partial update
|
||||
comes through, even though nothing about the device's actual
|
||||
supported options changed.
|
||||
|
||||
`apply()` is the sole path StateCache mutations flow through in
|
||||
this component (poll, sweep, and OBSERVE notify all funnel here),
|
||||
so `_cache_lock` serializes the read-then-write across those
|
||||
threads (DTLS reader for notifies, executor threads for poll/
|
||||
sweep) -- without it, two concurrent updates for the same href
|
||||
could each read the same prior rep and the second writer would
|
||||
silently lose the first's fields, reintroducing the exact bug
|
||||
this merge fixes.
|
||||
"""
|
||||
if self._is_settling(href):
|
||||
self.log.debug("dropping %s update for %s (settling)", source, href)
|
||||
return False
|
||||
return self.cache.apply_rep(href, rep, source=source)
|
||||
with self._cache_lock:
|
||||
merged = {**(self.cache.get(href) or {}), **rep}
|
||||
return self.cache.apply_rep(href, merged, source=source)
|
||||
|
||||
def on_notification(self, href: str, payload: bytes) -> None:
|
||||
"""Wired as DtlsCoapSession.on_notification. Runs on the DTLS
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
from typing import Optional
|
||||
|
||||
from ._base import DeviceRegistry
|
||||
from . import airconditioner, dishwasher, dryer, oven, refrigerator, washer
|
||||
from . import airconditioner, dishwasher, dryer, oven, range as _range, refrigerator, washer
|
||||
|
||||
__all__ = ['DeviceRegistry', '_type_key', 'for_device', 'for_device_by_model']
|
||||
|
||||
@@ -13,6 +13,7 @@ _REGISTRY_BY_KEY: dict[str, DeviceRegistry] = {
|
||||
'dishwasher': dishwasher.REGISTRY,
|
||||
'dryer': dryer.REGISTRY,
|
||||
'oven': oven.REGISTRY,
|
||||
'range': _range.REGISTRY,
|
||||
'refrigerator': refrigerator.REGISTRY,
|
||||
'washer': washer.REGISTRY,
|
||||
}
|
||||
@@ -101,4 +102,9 @@ def for_device_by_model(model_num: str, description: str) -> Optional[DeviceRegi
|
||||
# 'P' sits between the underscore and 'RAC' in that token).
|
||||
if key is None and '_RAC_' in (model_num or ''):
|
||||
key = 'airconditioner'
|
||||
# Range/cooktop-oven combos (e.g. TP1X_DA-KS-RANGE-0102X, issue #44) --
|
||||
# like the RAC/PRAC air conditioners above, these report no oneUiVersion
|
||||
# and don't match the washer/dryer/dishwasher consumer-prefix map either.
|
||||
if key is None and '-RANGE-' in (model_num or '').upper():
|
||||
key = 'range'
|
||||
return _REGISTRY_BY_KEY.get(key) if key else None
|
||||
|
||||
@@ -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,
|
||||
]),
|
||||
)
|
||||
@@ -206,6 +206,16 @@ CURRENT_LIMIT = Capability(
|
||||
# (/mode/vs/0 handled above, /temperatures/vs/0, /humidity/*) collide with
|
||||
# other families' schemas. A no-entity Capability still marks the href as
|
||||
# bound so discover() reports no coverage gap.
|
||||
#
|
||||
# CLIMATE_CONSUMED_HREFS carry the climate card's actual displayed state
|
||||
# (power, current/target temp, fan, swing, preset) -- the coordinator only
|
||||
# OBSERVE-subscribes and sub-polls 'hot'/'warm' hrefs (see coordinator.py),
|
||||
# so leaving these at the Capability default of 'cold' meant every state
|
||||
# change was invisible until the next full /device/0 summary sweep
|
||||
# (~30s -- issue #17: instant device response, 20-30s HA lag). Pin them to
|
||||
# 'warm' -- same tier as CLIMATE's own primary href -- so they get push
|
||||
# notifications (or, in poll-only mode, the warm sub-poll cadence) instead
|
||||
# of waiting on the summary sweep.
|
||||
# ---------------------------------------------------------------------------
|
||||
_AC_IGNORED = [
|
||||
# All-zero and ambiguously encoded on this model (2-value arrays); the
|
||||
@@ -236,4 +246,8 @@ _AC_IGNORED = [
|
||||
]
|
||||
|
||||
# Built as bare no-entity caps; folded into the AC registry (not global).
|
||||
COVERAGE = [Capability(href=h) for h in (CLIMATE_CONSUMED_HREFS + _AC_IGNORED)]
|
||||
COVERAGE = [
|
||||
Capability(href=h, poll_tier='warm') for h in CLIMATE_CONSUMED_HREFS
|
||||
] + [
|
||||
Capability(href=h) for h in _AC_IGNORED
|
||||
]
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -100,6 +100,12 @@ ICEMAKER_NIGHTTIME = Capability(
|
||||
# Icemaker (generic — covers /icemaker/one/vs/0, /icemaker/two/vs/0)
|
||||
# /icemaker/status/vs/0 is kept as exact-href cap and binds first.
|
||||
# /icemaker/nighttime/vs/0 is excluded by match_fn (lacks iceMaker.state).
|
||||
#
|
||||
# Entity names are derived from x.com.samsung.da.iceMaker.name ("CUBED_ICE",
|
||||
# "ICE_BITES") via name_field, not the href's "one"/"two" segment -- these two
|
||||
# ice makers are independent on/off toggles that can both be enabled at once
|
||||
# (issue #27), so they stay separate entities rather than a single ice-type
|
||||
# select, but users still want them labeled with the device's own names.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _icemaker_write(field):
|
||||
@@ -112,6 +118,7 @@ ICEMAKER_GENERIC = Capability(
|
||||
href=None,
|
||||
href_prefix='/icemaker/',
|
||||
match_fn=lambda rep, resources: 'x.com.samsung.da.iceMaker.state' in rep,
|
||||
name_field='x.com.samsung.da.iceMaker.name',
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
SensorDesc(key='making_status',
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
)
|
||||
@@ -15,6 +15,11 @@ class Capability:
|
||||
rt_filter: Optional[str] = None # bind only if rt_filter in rep.get('rt', ())
|
||||
href_prefix: Optional[str] = None # pattern caps only: bind only if href starts with this
|
||||
strip_prefix_in_key: bool = False # strip href_prefix segs before building key_override
|
||||
# Rep field holding this instance's device-given name (e.g. an ice
|
||||
# maker's "CUBED_ICE"/"ICE_BITES"), normalized and used as the display
|
||||
# name prefix in place of the href-derived instance label. Does not
|
||||
# affect key_override/unique_id -- only what's shown in the UI.
|
||||
name_field: Optional[str] = None
|
||||
match_fn: Optional[Callable[[dict, dict], bool]] = None # match_fn(rep, resources) -> bool
|
||||
# Rare optional hook — only operational-state-style resources use this.
|
||||
on_observation: Optional[Callable[[dict, dict], None]] = None
|
||||
|
||||
@@ -27,6 +27,25 @@ class BoundEntity:
|
||||
desc: SamsungEntityDescription
|
||||
instance: str = ''
|
||||
key_override: Optional[str] = None
|
||||
instance_name: Optional[str] = None
|
||||
|
||||
|
||||
def _snake_to_title(s: str) -> str:
|
||||
"""'CUBED_ICE'/'cubed_ice' -> 'Cubed Ice'. Shared with entity.py's
|
||||
_derive_name, which applies the same transform to a state key."""
|
||||
return s.replace('_', ' ').title()
|
||||
|
||||
|
||||
def _instance_name(cap: Capability, rep: dict) -> Optional[str]:
|
||||
"""Normalize `cap.name_field`'s raw value ("CUBED_ICE" -> "Cubed Ice")
|
||||
for use as a display-name prefix, or None if the cap doesn't declare
|
||||
one or the device didn't report it."""
|
||||
if not cap.name_field:
|
||||
return None
|
||||
raw = rep.get(cap.name_field)
|
||||
if not isinstance(raw, str) or not raw:
|
||||
return None
|
||||
return _snake_to_title(raw)
|
||||
|
||||
|
||||
def instance_suffix(href: str) -> str:
|
||||
@@ -37,6 +56,18 @@ def instance_suffix(href: str) -> str:
|
||||
return ''
|
||||
|
||||
|
||||
def _bind(cap: Capability, href: str, inst: str, inst_name: Optional[str],
|
||||
key_prefix: Optional[str] = None) -> list[BoundEntity]:
|
||||
"""Build one BoundEntity per entity on `cap`, sharing the instance/
|
||||
key-prefix/instance-name computed once by the caller."""
|
||||
return [
|
||||
BoundEntity(href=href, capability=cap, desc=desc, instance=inst,
|
||||
key_override=f'{key_prefix}_{desc.key}' if key_prefix else None,
|
||||
instance_name=inst_name)
|
||||
for desc in cap.entities
|
||||
]
|
||||
|
||||
|
||||
def discover(
|
||||
resources: dict[str, dict],
|
||||
registry: dict[str, list[Capability]],
|
||||
@@ -58,9 +89,7 @@ def discover(
|
||||
if cap.match_fn is not None and not cap.match_fn(rep, resources):
|
||||
continue
|
||||
inst = instance_suffix(href)
|
||||
for desc in cap.entities:
|
||||
out.append(BoundEntity(href=href, capability=cap,
|
||||
desc=desc, instance=inst))
|
||||
out.extend(_bind(cap, href, inst, _instance_name(cap, rep)))
|
||||
matched = True
|
||||
|
||||
if matched:
|
||||
@@ -78,10 +107,7 @@ def discover(
|
||||
# Auto-derive key prefix from href segments (skip digits and 'vs')
|
||||
src = href[len(cap.href_prefix):] if (cap.strip_prefix_in_key and cap.href_prefix) else href
|
||||
segs = [s for s in src.strip('/').split('/') if s and not s.isdigit() and s != 'vs']
|
||||
for desc in cap.entities:
|
||||
key_override = '_'.join(segs) + '_' + desc.key
|
||||
out.append(BoundEntity(href=href, capability=cap, desc=desc,
|
||||
instance=inst, key_override=key_override))
|
||||
out.extend(_bind(cap, href, inst, _instance_name(cap, rep), '_'.join(segs)))
|
||||
matched = True
|
||||
break
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -634,3 +638,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')
|
||||
|
||||
@@ -34,6 +34,28 @@ def test_apply_writes_through_when_not_settling():
|
||||
assert mgr.cache.get('/oven/vs/0') == {'a': 1}
|
||||
|
||||
|
||||
def test_apply_merges_partial_update_onto_prior_rep():
|
||||
"""Regression test for issue #27: a Bespoke fridge's /mode/vs/0 notify
|
||||
(and even a later sweep entry for that href) can carry only `modes`,
|
||||
omitting `supportedOptions` entirely. A full replace would wipe
|
||||
`supportedOptions` from the cache the moment that partial update
|
||||
arrives, even though the device's supported options didn't change --
|
||||
which is exactly what made the flex-zone select disappear."""
|
||||
mgr = _manager()
|
||||
full = {
|
||||
'x.com.samsung.da.modes': ['CVN_CONVERTIBLE_ZONE', 'CV_FDR_MEAT'],
|
||||
'x.com.samsung.da.supportedOptions': ['CV_FDR_WINE', 'CV_FDR_MEAT'],
|
||||
}
|
||||
mgr.apply('/mode/vs/0', full, source='poll')
|
||||
|
||||
partial = {'x.com.samsung.da.modes': ['CVN_CONVERTIBLE_ZONE', 'WATERFILTER_ENABLE']}
|
||||
mgr.apply('/mode/vs/0', partial, source='observe')
|
||||
|
||||
cached = mgr.cache.get('/mode/vs/0')
|
||||
assert cached['x.com.samsung.da.modes'] == ['CVN_CONVERTIBLE_ZONE', 'WATERFILTER_ENABLE']
|
||||
assert cached['x.com.samsung.da.supportedOptions'] == ['CV_FDR_WINE', 'CV_FDR_MEAT']
|
||||
|
||||
|
||||
def test_apply_drops_update_during_settle_window():
|
||||
mgr = _manager()
|
||||
mgr.cache.apply_rep('/oven/vs/0', {'a': 1}, source='seed')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -158,6 +158,46 @@ def test_discover_match_fn_decline_is_not_logged_as_gap():
|
||||
assert seen == []
|
||||
|
||||
|
||||
def test_discover_pattern_cap_reads_name_field():
|
||||
"""A pattern cap's name_field normalizes a device-given instance name
|
||||
(e.g. an ice maker's "CUBED_ICE") for use in the entity's display name,
|
||||
independent of the href-derived key (issue #27)."""
|
||||
cap = Capability(
|
||||
href=None,
|
||||
href_prefix='/icemaker/',
|
||||
name_field='x.com.samsung.da.iceMaker.name',
|
||||
entities=(BinarySensorDesc(key='enabled', field='x.com.samsung.da.iceMaker.state'),),
|
||||
)
|
||||
resources = {
|
||||
'/icemaker/one/vs/0': {
|
||||
'x.com.samsung.da.iceMaker.state': 'On',
|
||||
'x.com.samsung.da.iceMaker.name': 'CUBED_ICE',
|
||||
},
|
||||
'/icemaker/two/vs/0': {
|
||||
'x.com.samsung.da.iceMaker.state': 'On',
|
||||
'x.com.samsung.da.iceMaker.name': 'ICE_BITES',
|
||||
},
|
||||
}
|
||||
bound = discover(resources, {}, pattern_caps=[cap])
|
||||
names = {b.href: b.instance_name for b in bound}
|
||||
assert names == {
|
||||
'/icemaker/one/vs/0': 'Cubed Ice',
|
||||
'/icemaker/two/vs/0': 'Ice Bites',
|
||||
}
|
||||
|
||||
|
||||
def test_discover_pattern_cap_name_field_absent_leaves_instance_name_none():
|
||||
cap = Capability(
|
||||
href=None,
|
||||
href_prefix='/icemaker/',
|
||||
name_field='x.com.samsung.da.iceMaker.name',
|
||||
entities=(BinarySensorDesc(key='enabled', field='x.com.samsung.da.iceMaker.state'),),
|
||||
)
|
||||
resources = {'/icemaker/one/vs/0': {'x.com.samsung.da.iceMaker.state': 'On'}}
|
||||
bound = discover(resources, {}, pattern_caps=[cap])
|
||||
assert bound[0].instance_name is None
|
||||
|
||||
|
||||
def test_discover_rt_filter_gates_binding():
|
||||
"""Cap with rt_filter must not bind a rep whose rt list does not match."""
|
||||
oven_mode_cap = Capability(
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Tests for LocalThingsEntity's display-name derivation
|
||||
(custom_components/localthings/entity.py) -- the explicit-name,
|
||||
device-given-instance-name, and href-derived fallbacks.
|
||||
"""
|
||||
from custom_components.localthings.entity import LocalThingsEntity
|
||||
from custom_components.localthings.registry.capability import Capability
|
||||
from custom_components.localthings.registry.discovery import BoundEntity
|
||||
from custom_components.localthings.registry.entities import BinarySensorDesc
|
||||
|
||||
|
||||
class _FakeCoordinator:
|
||||
device_serial = 'TEST-SERIAL'
|
||||
|
||||
def __init__(self, last_resources=None):
|
||||
self.last_resources = last_resources or {}
|
||||
|
||||
|
||||
def _make_entity(desc, href='/x/vs/0', key_override=None, instance='', instance_name=None):
|
||||
capability = Capability(href=href, entities=(desc,))
|
||||
bound = BoundEntity(href=href, capability=capability, desc=desc,
|
||||
instance=instance, key_override=key_override,
|
||||
instance_name=instance_name)
|
||||
return LocalThingsEntity(_FakeCoordinator(), bound)
|
||||
|
||||
|
||||
def test_explicit_name_wins_over_everything():
|
||||
desc = BinarySensorDesc(key='enabled', name='Explicit Name')
|
||||
entity = _make_entity(desc, instance_name='Cubed Ice')
|
||||
assert entity._attr_name == 'Explicit Name'
|
||||
|
||||
|
||||
def test_instance_name_prefixes_the_derived_suffix():
|
||||
"""Issue #27: an ice maker's device-given name ("Cubed Ice") replaces
|
||||
the href-derived instance label ("Icemaker One") as the name prefix,
|
||||
keeping the same entity-specific suffix."""
|
||||
desc = BinarySensorDesc(key='enabled')
|
||||
entity = _make_entity(desc, key_override='icemaker_one_enabled',
|
||||
instance_name='Cubed Ice')
|
||||
assert entity._attr_name == 'Cubed Ice Enabled'
|
||||
|
||||
|
||||
def test_no_instance_name_falls_back_to_derived_state_key():
|
||||
desc = BinarySensorDesc(key='enabled')
|
||||
entity = _make_entity(desc, key_override='icemaker_one_enabled')
|
||||
assert entity._attr_name == 'Icemaker One Enabled'
|
||||
@@ -199,6 +199,23 @@ def test_registry_reproduces_golden_state_keys_for_tp1x_rac():
|
||||
)
|
||||
|
||||
|
||||
def test_registry_reproduces_golden_state_keys_for_range():
|
||||
"""Range/cooktop-oven combo (model TP1X_DA-KS-RANGE-0102X, issue #44) --
|
||||
reports no oneUiVersion; resolved via the '-RANGE-' modelNum token
|
||||
fallback in for_device_by_model. Reuses the oven family's cavity/
|
||||
setpoint/mode/operational-state capabilities and adds 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