Compare commits
30
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
760d797c12 | ||
|
|
b944549f55 | ||
|
|
94551da4a0 | ||
|
|
f783aa72b9 | ||
|
|
ed461d82cd | ||
|
|
62d4ed673b | ||
|
|
e74089a545 | ||
|
|
b2f3e501e5 | ||
|
|
8ca2c3cb16 | ||
|
|
aec3c5e460 | ||
|
|
321f186b0e | ||
|
|
2f6b7d54e3 | ||
|
|
6e5fee32b8 | ||
|
|
1e9437ef3c | ||
|
|
36f7aa0b8c | ||
|
|
10ab51c7f3 | ||
|
|
8bf9002eb7 | ||
|
|
fb0f24a175 | ||
|
|
5944d14bca | ||
|
|
1493467c63 | ||
|
|
61ee2972dc | ||
|
|
3d5263d792 | ||
|
|
d6639c99d4 | ||
|
|
5d9b470d7e | ||
|
|
9800cd0aa8 | ||
|
|
3469f57080 | ||
|
|
f54daf3608 | ||
|
|
e49b61e01e | ||
|
|
dbe70ffc5b | ||
|
|
7d011bfe89 |
No files matched your search
@@ -19,6 +19,7 @@ Your state stays on your LAN: HA talks to the appliance over a direct DTLS sessi
|
|||||||
| Air conditioner | `by_type/airconditioner.py` |
|
| Air conditioner | `by_type/airconditioner.py` |
|
||||||
| Dryer | `by_type/dryer.py` |
|
| Dryer | `by_type/dryer.py` |
|
||||||
| Oven | `by_type/oven.py` |
|
| Oven | `by_type/oven.py` |
|
||||||
|
| Range / cooktop-oven combo | `by_type/range.py` |
|
||||||
| Dishwasher | `by_type/dishwasher.py` |
|
| Dishwasher | `by_type/dishwasher.py` |
|
||||||
| Refrigerator | `by_type/refrigerator.py` |
|
| Refrigerator | `by_type/refrigerator.py` |
|
||||||
| Washer | `by_type/washer.py` |
|
| Washer | `by_type/washer.py` |
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import cbor2
|
|||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.core import HomeAssistant, callback
|
from homeassistant.core import HomeAssistant, callback
|
||||||
|
from homeassistant.exceptions import ServiceValidationError
|
||||||
from homeassistant.helpers import issue_registry as ir
|
from homeassistant.helpers import issue_registry as ir
|
||||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||||
from homeassistant.helpers.device_registry import DeviceInfo
|
from homeassistant.helpers.device_registry import DeviceInfo
|
||||||
@@ -21,6 +22,7 @@ from smartthings_local.ocf.state_cache import StateCache
|
|||||||
|
|
||||||
from .registry.batch import parse_device0_batch
|
from .registry.batch import parse_device0_batch
|
||||||
from .registry.by_type import for_device, for_device_by_model
|
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.discovery import discover, BoundEntity
|
||||||
from .registry import CAPABILITIES
|
from .registry import CAPABILITIES
|
||||||
from .registry.adapter import flatten
|
from .registry.adapter import flatten
|
||||||
@@ -36,6 +38,12 @@ _LOGGER = logging.getLogger(__name__)
|
|||||||
|
|
||||||
_SEED_PATH = ['device', '0']
|
_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:
|
class _NoOpDescriptor:
|
||||||
"""StateCache requires a descriptor with an on_observation hook. This
|
"""StateCache requires a descriptor with an on_observation hook. This
|
||||||
@@ -509,24 +517,49 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
|
|
||||||
async def async_send_command(self, bound_entity: BoundEntity,
|
async def async_send_command(self, bound_entity: BoundEntity,
|
||||||
payload: Any) -> None:
|
payload: Any) -> None:
|
||||||
"""Write a value to the device. Fire-and-forget style."""
|
"""Write a value to the device. Fire-and-forget style.
|
||||||
|
|
||||||
|
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. The remote-control
|
||||||
|
check runs first and applies to every platform unconditionally,
|
||||||
|
ahead of any description-specific validate_fn."""
|
||||||
desc = bound_entity.desc
|
desc = bound_entity.desc
|
||||||
write_fn = getattr(desc, 'write_fn', None)
|
write_fn = getattr(desc, 'write_fn', None)
|
||||||
if write_fn is None:
|
if write_fn is None:
|
||||||
return
|
return
|
||||||
href = bound_entity.href
|
href = bound_entity.href
|
||||||
rep = self._cache.get(href or '') or {}
|
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, resources)
|
||||||
|
if error:
|
||||||
|
raise ServiceValidationError(error)
|
||||||
result = write_fn(payload, rep, href)
|
result = write_fn(payload, rep, href)
|
||||||
if result is None:
|
if result is None:
|
||||||
self._log.warning("write_fn rejected payload %r for %s", payload, href)
|
self._log.warning("write_fn rejected payload %r for %s", payload, href)
|
||||||
return
|
return
|
||||||
path_segs, body = result
|
path_segs, body = result
|
||||||
|
|
||||||
|
# Apply the write optimistically before starting the settle guard,
|
||||||
|
# not after -- mark_write_pending gates every source (poll, sweep,
|
||||||
|
# observe) through the same apply(), itself included, so flipping
|
||||||
|
# this order would have the guard drop the one update it exists to
|
||||||
|
# protect. Without an optimistic value in the cache for it to hold
|
||||||
|
# onto, the settle window was just delaying the real device
|
||||||
|
# confirmation for a few seconds on every write, which read exactly
|
||||||
|
# like the write being silently reverted (issue #27).
|
||||||
|
self._observe.apply(href, body, source='optimistic')
|
||||||
|
self._observe.mark_write_pending(href)
|
||||||
|
|
||||||
def _do_put():
|
def _do_put():
|
||||||
sess = self._session
|
sess = self._session
|
||||||
if sess is None:
|
if sess is None:
|
||||||
raise RuntimeError("no session")
|
raise RuntimeError("no session")
|
||||||
self._observe.mark_write_pending(href)
|
|
||||||
code, _ = sess.post(path_segs, cbor2.dumps(body), timeout=8.0)
|
code, _ = sess.post(path_segs, cbor2.dumps(body), timeout=8.0)
|
||||||
self._log.info("PUT %s → code %#04x", href, code)
|
self._log.info("PUT %s → code %#04x", href, code)
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from homeassistant.helpers.device_registry import DeviceInfo
|
|||||||
from homeassistant.const import EntityCategory
|
from homeassistant.const import EntityCategory
|
||||||
|
|
||||||
from .registry.adapter import _key
|
from .registry.adapter import _key
|
||||||
from .registry.discovery import BoundEntity
|
from .registry.discovery import BoundEntity, _snake_to_title
|
||||||
|
|
||||||
from .const import DOMAIN
|
from .const import DOMAIN
|
||||||
from .coordinator import LocalThingsCoordinator
|
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".
|
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)
|
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]):
|
class LocalThingsEntity(CoordinatorEntity[LocalThingsCoordinator]):
|
||||||
@@ -57,7 +57,15 @@ class LocalThingsEntity(CoordinatorEntity[LocalThingsCoordinator]):
|
|||||||
self._bound = bound
|
self._bound = bound
|
||||||
self._state_key = _key(bound)
|
self._state_key = _key(bound)
|
||||||
self._attr_unique_id = f"{DOMAIN}_{coordinator.device_serial}_{self._state_key}"
|
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_translation_key = bound.desc.translation_key
|
||||||
self._attr_icon = bound.desc.icon
|
self._attr_icon = bound.desc.icon
|
||||||
raw_cat = bound.desc.entity_category
|
raw_cat = bound.desc.entity_category
|
||||||
|
|||||||
@@ -12,5 +12,5 @@
|
|||||||
"pyOpenSSL>=23.0",
|
"pyOpenSSL>=23.0",
|
||||||
"smartthings-local>=0.1.0"
|
"smartthings-local>=0.1.0"
|
||||||
],
|
],
|
||||||
"version": "0.9.0"
|
"version": "0.10.1"
|
||||||
}
|
}
|
||||||
@@ -58,6 +58,9 @@ class LocalThingsNumber(LocalThingsEntity, NumberEntity):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def native_min_value(self) -> float:
|
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()
|
r = self._range_from_resource()
|
||||||
if r is not None:
|
if r is not None:
|
||||||
return float(r[0])
|
return float(r[0])
|
||||||
@@ -67,6 +70,9 @@ class LocalThingsNumber(LocalThingsEntity, NumberEntity):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def native_max_value(self) -> float:
|
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()
|
r = self._range_from_resource()
|
||||||
if r is not None:
|
if r is not None:
|
||||||
return float(r[1])
|
return float(r[1])
|
||||||
@@ -74,6 +80,15 @@ class LocalThingsNumber(LocalThingsEntity, NumberEntity):
|
|||||||
return self._attr_native_max_value
|
return self._attr_native_max_value
|
||||||
return super().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
|
@property
|
||||||
def native_value(self):
|
def native_value(self):
|
||||||
return (self.coordinator.data or {}).get(self._state_key)
|
return (self.coordinator.data or {}).get(self._state_key)
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ class ObserveManager:
|
|||||||
self.last_mode_change_wall = time.time()
|
self.last_mode_change_wall = time.time()
|
||||||
self._settle_until: dict[str, float] = {}
|
self._settle_until: dict[str, float] = {}
|
||||||
self._settle_lock = threading.Lock()
|
self._settle_lock = threading.Lock()
|
||||||
|
self._cache_lock = threading.Lock()
|
||||||
self.subscribed_hrefs: set[str] = set()
|
self.subscribed_hrefs: set[str] = set()
|
||||||
self._notified: set[str] = set()
|
self._notified: set[str] = set()
|
||||||
self._last_notify_ts: float | None = None
|
self._last_notify_ts: float | None = None
|
||||||
@@ -92,11 +93,35 @@ class ObserveManager:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
def apply(self, href: str, rep: dict, source: str) -> bool:
|
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):
|
if self._is_settling(href):
|
||||||
self.log.debug("dropping %s update for %s (settling)", source, href)
|
self.log.debug("dropping %s update for %s (settling)", source, href)
|
||||||
return False
|
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:
|
def on_notification(self, href: str, payload: bytes) -> None:
|
||||||
"""Wired as DtlsCoapSession.on_notification. Runs on the DTLS
|
"""Wired as DtlsCoapSession.on_notification. Runs on the DTLS
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from ._base import DeviceRegistry
|
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']
|
__all__ = ['DeviceRegistry', '_type_key', 'for_device', 'for_device_by_model']
|
||||||
|
|
||||||
@@ -13,6 +13,7 @@ _REGISTRY_BY_KEY: dict[str, DeviceRegistry] = {
|
|||||||
'dishwasher': dishwasher.REGISTRY,
|
'dishwasher': dishwasher.REGISTRY,
|
||||||
'dryer': dryer.REGISTRY,
|
'dryer': dryer.REGISTRY,
|
||||||
'oven': oven.REGISTRY,
|
'oven': oven.REGISTRY,
|
||||||
|
'range': _range.REGISTRY,
|
||||||
'refrigerator': refrigerator.REGISTRY,
|
'refrigerator': refrigerator.REGISTRY,
|
||||||
'washer': washer.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).
|
# 'P' sits between the underscore and 'RAC' in that token).
|
||||||
if key is None and '_RAC_' in (model_num or ''):
|
if key is None and '_RAC_' in (model_num or ''):
|
||||||
key = 'airconditioner'
|
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
|
return _REGISTRY_BY_KEY.get(key) if key else None
|
||||||
@@ -3,23 +3,20 @@
|
|||||||
The first device whose core controls surface as a single composite HA `climate`
|
The first device whose core controls surface as a single composite HA `climate`
|
||||||
entity (see capabilities/airconditioner.py and climate.py). Power/mode/temp/wind
|
entity (see capabilities/airconditioner.py and climate.py). Power/mode/temp/wind
|
||||||
are consumed by that entity rather than exposed as separate switches/selects, so
|
are consumed by that entity rather than exposed as separate switches/selects, so
|
||||||
this registry deliberately does NOT include the common POWER caps -- on/off is
|
this registry includes *common.UNIVERSAL but deliberately NOT common.POWER --
|
||||||
the climate entity's HVACMode.OFF / TURN_ON/OFF.
|
on/off is the climate entity's HVACMode.OFF / TURN_ON/OFF. See common.POWER's
|
||||||
|
own comment in capabilities/common.py for why it's excluded.
|
||||||
|
|
||||||
Reuses common.ALARMS + common.ENERGY_METER, fridge.FIRMWARE_UPDATE (as every
|
Reuses dishwasher.DIAGNOSIS for /diagnosis/vs/0.
|
||||||
registry does), and dishwasher.DIAGNOSIS for /diagnosis/vs/0.
|
|
||||||
"""
|
"""
|
||||||
from ..capabilities import airconditioner, common, dishwasher, fridge, ignored
|
from ..capabilities import airconditioner, common, dishwasher, ignored
|
||||||
from ._base import DeviceRegistry, _build
|
from ._base import DeviceRegistry, _build
|
||||||
|
|
||||||
REGISTRY = DeviceRegistry(
|
REGISTRY = DeviceRegistry(
|
||||||
name='airconditioner',
|
name='airconditioner',
|
||||||
capabilities=_build([
|
capabilities=_build([
|
||||||
*ignored.IGNORED,
|
*ignored.IGNORED,
|
||||||
common.ALARMS,
|
*common.UNIVERSAL,
|
||||||
common.ENERGY_METER,
|
|
||||||
fridge.FIRMWARE_UPDATE,
|
|
||||||
fridge.SELF_CHECK,
|
|
||||||
dishwasher.DIAGNOSIS,
|
dishwasher.DIAGNOSIS,
|
||||||
airconditioner.CLIMATE,
|
airconditioner.CLIMATE,
|
||||||
airconditioner.AIR_PURIFY,
|
airconditioner.AIR_PURIFY,
|
||||||
|
|||||||
@@ -1,19 +1,13 @@
|
|||||||
"""Dishwasher device registry."""
|
"""Dishwasher device registry."""
|
||||||
from ..capabilities import common, dishwasher, fridge, ignored, laundry, operational
|
from ..capabilities import common, dishwasher, ignored, laundry, operational
|
||||||
from ._base import DeviceRegistry, _build
|
from ._base import DeviceRegistry, _build
|
||||||
|
|
||||||
REGISTRY = DeviceRegistry(
|
REGISTRY = DeviceRegistry(
|
||||||
name='dishwasher',
|
name='dishwasher',
|
||||||
capabilities=_build([
|
capabilities=_build([
|
||||||
*ignored.IGNORED,
|
*ignored.IGNORED,
|
||||||
common.POWER_GENERIC,
|
*common.UNIVERSAL,
|
||||||
common.POWER_VS_FALLBACK,
|
*common.POWER,
|
||||||
common.KIDS_LOCK_GENERIC,
|
|
||||||
common.KIDS_LOCK_VS_FALLBACK,
|
|
||||||
common.REMOTE_CONTROL_GENERIC,
|
|
||||||
common.REMOTE_CONTROL_VS_FALLBACK,
|
|
||||||
common.ALARMS,
|
|
||||||
common.ENERGY_METER,
|
|
||||||
common.WATER_METER,
|
common.WATER_METER,
|
||||||
common.WATER_FILTER,
|
common.WATER_FILTER,
|
||||||
operational.OPERATIONAL_STATE,
|
operational.OPERATIONAL_STATE,
|
||||||
@@ -25,6 +19,5 @@ REGISTRY = DeviceRegistry(
|
|||||||
laundry.DOOR_LED,
|
laundry.DOOR_LED,
|
||||||
laundry.SOUND_MODE,
|
laundry.SOUND_MODE,
|
||||||
laundry.SOUND_VOLUME,
|
laundry.SOUND_VOLUME,
|
||||||
fridge.FIRMWARE_UPDATE,
|
|
||||||
]),
|
]),
|
||||||
)
|
)
|
||||||
@@ -9,21 +9,15 @@ objects the washer registry uses -- washer and dryer expose the same
|
|||||||
DA_WM_-family surface, so they stay consistent instead of each carrying a
|
DA_WM_-family surface, so they stay consistent instead of each carrying a
|
||||||
bespoke variant.
|
bespoke variant.
|
||||||
"""
|
"""
|
||||||
from ..capabilities import common, dryer, fridge, ignored, laundry, operational
|
from ..capabilities import common, dryer, ignored, laundry, operational
|
||||||
from ._base import DeviceRegistry, _build
|
from ._base import DeviceRegistry, _build
|
||||||
|
|
||||||
REGISTRY = DeviceRegistry(
|
REGISTRY = DeviceRegistry(
|
||||||
name='dryer',
|
name='dryer',
|
||||||
capabilities=_build([
|
capabilities=_build([
|
||||||
*ignored.IGNORED,
|
*ignored.IGNORED,
|
||||||
common.POWER_GENERIC,
|
*common.UNIVERSAL,
|
||||||
common.POWER_VS_FALLBACK,
|
*common.POWER,
|
||||||
common.KIDS_LOCK_GENERIC,
|
|
||||||
common.KIDS_LOCK_VS_FALLBACK,
|
|
||||||
common.REMOTE_CONTROL_GENERIC,
|
|
||||||
common.REMOTE_CONTROL_VS_FALLBACK,
|
|
||||||
common.ALARMS,
|
|
||||||
common.ENERGY_METER,
|
|
||||||
operational.OPERATIONAL_STATE,
|
operational.OPERATIONAL_STATE,
|
||||||
laundry.DOOR_LED,
|
laundry.DOOR_LED,
|
||||||
laundry.SOUND_MODE,
|
laundry.SOUND_MODE,
|
||||||
@@ -32,6 +26,5 @@ REGISTRY = DeviceRegistry(
|
|||||||
dryer.DRYER_SETTINGS,
|
dryer.DRYER_SETTINGS,
|
||||||
dryer.DRYER_COURSE,
|
dryer.DRYER_COURSE,
|
||||||
dryer.DRYER_DIAGNOSIS,
|
dryer.DRYER_DIAGNOSIS,
|
||||||
fridge.FIRMWARE_UPDATE,
|
|
||||||
]),
|
]),
|
||||||
)
|
)
|
||||||
@@ -1,24 +1,19 @@
|
|||||||
"""Oven device registry."""
|
"""Oven device registry."""
|
||||||
from ..capabilities import common, fridge, ignored, oven
|
from ..capabilities import common, ignored, oven
|
||||||
from ._base import DeviceRegistry, _build
|
from ._base import DeviceRegistry, _build
|
||||||
|
|
||||||
REGISTRY = DeviceRegistry(
|
REGISTRY = DeviceRegistry(
|
||||||
name='oven',
|
name='oven',
|
||||||
capabilities=_build([
|
capabilities=_build([
|
||||||
*ignored.IGNORED,
|
*ignored.IGNORED,
|
||||||
common.POWER_GENERIC,
|
*common.UNIVERSAL,
|
||||||
common.POWER_VS_FALLBACK,
|
*common.POWER,
|
||||||
common.KIDS_LOCK_GENERIC,
|
|
||||||
common.KIDS_LOCK_VS_FALLBACK,
|
|
||||||
common.REMOTE_CONTROL_GENERIC,
|
|
||||||
common.REMOTE_CONTROL_VS_FALLBACK,
|
|
||||||
common.ALARMS,
|
|
||||||
oven.OVEN_CAVITY,
|
oven.OVEN_CAVITY,
|
||||||
oven.OVEN_SETPOINT,
|
oven.OVEN_SETPOINT,
|
||||||
oven.OVEN_MODE,
|
oven.OVEN_MODE,
|
||||||
oven.OVEN_OPERATIONAL_STATE,
|
oven.OVEN_OPERATIONAL_STATE,
|
||||||
oven.OVEN_DOOR,
|
oven.OVEN_DOOR,
|
||||||
oven.OVEN_CONNECTED,
|
oven.OVEN_CONNECTED,
|
||||||
fridge.FIRMWARE_UPDATE,
|
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,
|
||||||
|
]),
|
||||||
|
)
|
||||||
@@ -6,21 +6,14 @@ REGISTRY = DeviceRegistry(
|
|||||||
name='refrigerator',
|
name='refrigerator',
|
||||||
capabilities=_build([
|
capabilities=_build([
|
||||||
*ignored.IGNORED,
|
*ignored.IGNORED,
|
||||||
common.POWER_GENERIC,
|
*common.UNIVERSAL,
|
||||||
common.POWER_VS_FALLBACK,
|
*common.POWER,
|
||||||
fridge.STATUS_LOCK,
|
fridge.STATUS_LOCK,
|
||||||
fridge.DOOR_ALERT,
|
fridge.DOOR_ALERT,
|
||||||
common.KIDS_LOCK_GENERIC,
|
|
||||||
common.KIDS_LOCK_VS_FALLBACK,
|
|
||||||
common.REMOTE_CONTROL_GENERIC,
|
|
||||||
common.REMOTE_CONTROL_VS_FALLBACK,
|
|
||||||
common.ALARMS,
|
|
||||||
common.ENERGY_METER,
|
|
||||||
common.WATER_FILTER,
|
common.WATER_FILTER,
|
||||||
dishwasher.DIAGNOSIS,
|
dishwasher.DIAGNOSIS,
|
||||||
fridge.ICEMAKER_NIGHTTIME,
|
fridge.ICEMAKER_NIGHTTIME,
|
||||||
fridge.FLEX_ZONE,
|
fridge.FLEX_ZONE,
|
||||||
fridge.AI_ENERGY_LEVEL,
|
|
||||||
fridge.REFRIGERATION,
|
fridge.REFRIGERATION,
|
||||||
fridge.AUTOFILL,
|
fridge.AUTOFILL,
|
||||||
fridge.WELCOME_LIGHTING,
|
fridge.WELCOME_LIGHTING,
|
||||||
@@ -29,11 +22,9 @@ REGISTRY = DeviceRegistry(
|
|||||||
fridge.SABBATH,
|
fridge.SABBATH,
|
||||||
fridge.BEVERAGE_ZONE,
|
fridge.BEVERAGE_ZONE,
|
||||||
fridge.PANTRY_ZONE,
|
fridge.PANTRY_ZONE,
|
||||||
fridge.FIRMWARE_UPDATE,
|
|
||||||
fridge.DEFROST_DELAY,
|
fridge.DEFROST_DELAY,
|
||||||
fridge.DEFROST_DELAY_NATIVE_DUPLICATE,
|
fridge.DEFROST_DELAY_NATIVE_DUPLICATE,
|
||||||
fridge.DEFROST_BLOCK_STATUS,
|
fridge.DEFROST_BLOCK_STATUS,
|
||||||
fridge.SELF_CHECK,
|
|
||||||
fridge.DOORS_FALLBACK,
|
fridge.DOORS_FALLBACK,
|
||||||
fridge.TEMPERATURES_FALLBACK,
|
fridge.TEMPERATURES_FALLBACK,
|
||||||
fridge.ICEMAKER_STATUS_FALLBACK,
|
fridge.ICEMAKER_STATUS_FALLBACK,
|
||||||
|
|||||||
@@ -1,26 +1,19 @@
|
|||||||
"""Washer device registry."""
|
"""Washer device registry."""
|
||||||
from ..capabilities import common, dishwasher, fridge, ignored, laundry, operational, washer
|
from ..capabilities import common, dishwasher, ignored, laundry, operational, washer
|
||||||
from ._base import DeviceRegistry, _build
|
from ._base import DeviceRegistry, _build
|
||||||
|
|
||||||
REGISTRY = DeviceRegistry(
|
REGISTRY = DeviceRegistry(
|
||||||
name='washer',
|
name='washer',
|
||||||
capabilities=_build([
|
capabilities=_build([
|
||||||
*ignored.IGNORED,
|
*ignored.IGNORED,
|
||||||
common.POWER_GENERIC,
|
*common.UNIVERSAL,
|
||||||
common.POWER_VS_FALLBACK,
|
*common.POWER,
|
||||||
common.KIDS_LOCK_GENERIC,
|
|
||||||
common.KIDS_LOCK_VS_FALLBACK,
|
|
||||||
common.REMOTE_CONTROL_GENERIC,
|
|
||||||
common.REMOTE_CONTROL_VS_FALLBACK,
|
|
||||||
washer.WASHER_SETTINGS,
|
washer.WASHER_SETTINGS,
|
||||||
washer.WASHER_COURSE,
|
washer.WASHER_COURSE,
|
||||||
laundry.BUZZER_SOUND,
|
laundry.BUZZER_SOUND,
|
||||||
laundry.JOB_BEGINNING_STATUS,
|
laundry.JOB_BEGINNING_STATUS,
|
||||||
common.ALARMS,
|
|
||||||
common.ENERGY_METER,
|
|
||||||
common.WATER_METER,
|
common.WATER_METER,
|
||||||
operational.OPERATIONAL_STATE,
|
operational.OPERATIONAL_STATE,
|
||||||
dishwasher.DIAGNOSIS,
|
dishwasher.DIAGNOSIS,
|
||||||
fridge.FIRMWARE_UPDATE,
|
|
||||||
]),
|
]),
|
||||||
)
|
)
|
||||||
@@ -206,6 +206,16 @@ CURRENT_LIMIT = Capability(
|
|||||||
# (/mode/vs/0 handled above, /temperatures/vs/0, /humidity/*) collide with
|
# (/mode/vs/0 handled above, /temperatures/vs/0, /humidity/*) collide with
|
||||||
# other families' schemas. A no-entity Capability still marks the href as
|
# other families' schemas. A no-entity Capability still marks the href as
|
||||||
# bound so discover() reports no coverage gap.
|
# 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 = [
|
_AC_IGNORED = [
|
||||||
# All-zero and ambiguously encoded on this model (2-value arrays); the
|
# All-zero and ambiguously encoded on this model (2-value arrays); the
|
||||||
@@ -219,8 +229,9 @@ _AC_IGNORED = [
|
|||||||
# user-actionable state or no documented write contract, so per the
|
# user-actionable state or no documented write contract, so per the
|
||||||
# 'don't guess' rule they are ignored rather than modeled.
|
# 'don't guess' rule they are ignored rather than modeled.
|
||||||
# /option/muteonce/vs/0 and /selfcheck/vs/0 are deliberately NOT here --
|
# /option/muteonce/vs/0 and /selfcheck/vs/0 are deliberately NOT here --
|
||||||
# see MUTE_ONCE and fridge.SELF_CHECK above/in the by_type registry,
|
# see MUTE_ONCE above and common.SELF_CHECK (via common.UNIVERSAL) in
|
||||||
# both of which have a confirmed, cleanly modelable contract.
|
# the by_type registry, both of which have a confirmed, cleanly
|
||||||
|
# modelable contract.
|
||||||
'/airlevelcheck/vs/0', # periodic air-quality sensing scheduler plumbing
|
'/airlevelcheck/vs/0', # periodic air-quality sensing scheduler plumbing
|
||||||
'/aisleep/vs/0', # AI-sleep feedback state (no actionable control)
|
'/aisleep/vs/0', # AI-sleep feedback state (no actionable control)
|
||||||
'/availablecontrolsets/vs/0', # opaque hex-encoded control-set bitmap
|
'/availablecontrolsets/vs/0', # opaque hex-encoded control-set bitmap
|
||||||
@@ -235,4 +246,8 @@ _AC_IGNORED = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
# Built as bare no-entity caps; folded into the AC registry (not global).
|
# 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
|
||||||
|
]
|
||||||
@@ -11,7 +11,9 @@ against live device dumps:
|
|||||||
/filter/waterfilter/vs/0 -> x.com.samsung.da.filterUsage / filterStatus
|
/filter/waterfilter/vs/0 -> x.com.samsung.da.filterUsage / filterStatus
|
||||||
"""
|
"""
|
||||||
from ..capability import Capability
|
from ..capability import Capability
|
||||||
from ..entities import BinarySensorDesc, SensorDesc, SwitchDesc
|
from ..entities import (
|
||||||
|
BinarySensorDesc, ButtonDesc, SelectDesc, SensorDesc, SwitchDesc,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _num(v):
|
def _num(v):
|
||||||
@@ -117,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(
|
REMOTE_CONTROL_GENERIC = Capability(
|
||||||
href='/remotectrl/0',
|
href='/remotectrl/0',
|
||||||
|
poll_tier='warm',
|
||||||
entities=(
|
entities=(
|
||||||
BinarySensorDesc(key='remote_control', field='value',
|
BinarySensorDesc(key='remote_control', field='value',
|
||||||
name='Smart Control', device_class='connectivity',
|
name='Smart Control', device_class='connectivity',
|
||||||
@@ -129,6 +153,7 @@ REMOTE_CONTROL_GENERIC = Capability(
|
|||||||
REMOTE_CONTROL_VS_FALLBACK = Capability(
|
REMOTE_CONTROL_VS_FALLBACK = Capability(
|
||||||
href='/remotectrl/vs/0',
|
href='/remotectrl/vs/0',
|
||||||
match_fn=lambda rep, resources: '/remotectrl/0' not in resources,
|
match_fn=lambda rep, resources: '/remotectrl/0' not in resources,
|
||||||
|
poll_tier='warm',
|
||||||
entities=(
|
entities=(
|
||||||
BinarySensorDesc(key='remote_control',
|
BinarySensorDesc(key='remote_control',
|
||||||
field='x.com.samsung.da.remoteControlEnabled',
|
field='x.com.samsung.da.remoteControlEnabled',
|
||||||
@@ -231,3 +256,162 @@ WATER_FILTER = Capability(
|
|||||||
name='Filter status', icon='mdi:filter-check'),
|
name='Filter status', icon='mdi:filter-check'),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# AI energy-saving level -- '0' is off, and supportedAiLevel lists the
|
||||||
|
# additional level(s) the device offers ('1' meaning just "on" on most
|
||||||
|
# hardware, but multi-level boards have been reported). Verified cross-family:
|
||||||
|
# fridge (issue #21) and washer (issue #40) both expose this href.
|
||||||
|
#
|
||||||
|
# supportedAiLevel is a single-entry list on most captured hardware, where a
|
||||||
|
# select would offer only one real choice against an implicit "off" -- shown
|
||||||
|
# as a switch instead. '0' itself is never in supportedAiLevel but has been
|
||||||
|
# observed live as the off value of aiLevel, so the select synthesizes it
|
||||||
|
# back in as an explicit option rather than leaving no way to turn off.
|
||||||
|
#
|
||||||
|
# No translation_key: aiLevel's values are plain digit strings, and
|
||||||
|
# select.py's _display() already renders an untranslated numeric string
|
||||||
|
# as-is -- there's nothing a strings.json entry adds that's worth maintaining
|
||||||
|
# against an unknown, growing number of future levels.
|
||||||
|
|
||||||
|
|
||||||
|
def _ai_energy_supported_levels(rep):
|
||||||
|
"""supportedAiLevel as a list -- a stray scalar (e.g. a string) must not
|
||||||
|
be len()-checked as if it were a list."""
|
||||||
|
sl = rep.get('supportedAiLevel')
|
||||||
|
return list(sl) if isinstance(sl, (list, tuple)) else []
|
||||||
|
|
||||||
|
|
||||||
|
def _ai_energy_level_options(resources):
|
||||||
|
rep = resources.get('/energy/ailevel/vs/0') or {}
|
||||||
|
return ['0', *_ai_energy_supported_levels(rep)]
|
||||||
|
|
||||||
|
|
||||||
|
def _ai_energy_level_write(p, rep, href=None):
|
||||||
|
return ['energy', 'ailevel', 'vs', '0'], {'aiLevel': p}
|
||||||
|
|
||||||
|
|
||||||
|
def _ai_energy_level_switch_write(p, rep, href=None):
|
||||||
|
levels = _ai_energy_supported_levels(rep)
|
||||||
|
on_level = levels[0] if levels else '1'
|
||||||
|
return ['energy', 'ailevel', 'vs', '0'], {'aiLevel': on_level if p == 'On' else '0'}
|
||||||
|
|
||||||
|
|
||||||
|
AI_ENERGY_LEVEL = Capability(
|
||||||
|
href='/energy/ailevel/vs/0',
|
||||||
|
poll_tier='cold',
|
||||||
|
entities=(
|
||||||
|
# No `not rep` stub carve-out on either side, unlike most exists_fn
|
||||||
|
# gates in this file -- entity creation only ever runs once, against
|
||||||
|
# whichever snapshot happens to be current the moment platforms are
|
||||||
|
# set up (see entity._is_included / __init__.py's
|
||||||
|
# async_config_entry_first_refresh-before-forward-entry-setups
|
||||||
|
# ordering), while flatten() re-evaluates exists_fn every poll
|
||||||
|
# against live data. Both descriptors share key='ai_energy_level',
|
||||||
|
# so if a stub carve-out let one of them win at setup time while the
|
||||||
|
# other wins once real data lands, flatten() would feed the
|
||||||
|
# instantiated entity a value shaped for the other platform (e.g. a
|
||||||
|
# bool into a Select). Requiring real, populated data on both sides
|
||||||
|
# keeps the entity-creation decision and the live-value decision in
|
||||||
|
# permanent agreement -- the cost is this entity doesn't appear
|
||||||
|
# until a reload if the device's very first poll stubs this
|
||||||
|
# cold-tier href, the same reload already required to fix which
|
||||||
|
# platform got picked in that case.
|
||||||
|
SwitchDesc(key='ai_energy_level', field='aiLevel',
|
||||||
|
name='AI energy level', icon='mdi:leaf',
|
||||||
|
entity_category='config',
|
||||||
|
value_fn=lambda v: v != '0',
|
||||||
|
exists_fn=lambda rep, resources: (
|
||||||
|
len(_ai_energy_supported_levels(rep)) == 1),
|
||||||
|
write_fn=_ai_energy_level_switch_write),
|
||||||
|
SelectDesc(key='ai_energy_level', field='aiLevel',
|
||||||
|
name='AI energy level', icon='mdi:leaf',
|
||||||
|
entity_category='config',
|
||||||
|
options=_ai_energy_level_options,
|
||||||
|
exists_fn=lambda rep, resources: (
|
||||||
|
len(_ai_energy_supported_levels(rep)) > 1),
|
||||||
|
write_fn=_ai_energy_level_write),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
FIRMWARE_UPDATE = Capability(
|
||||||
|
href='/otninformation/vs/0',
|
||||||
|
poll_tier='cold',
|
||||||
|
entities=(
|
||||||
|
BinarySensorDesc(
|
||||||
|
key='firmware_update',
|
||||||
|
field='x.com.samsung.da.newVersionAvailable',
|
||||||
|
name='Firmware update available',
|
||||||
|
device_class='update',
|
||||||
|
entity_category='diagnostic',
|
||||||
|
value_fn=lambda v: str(v).lower() == 'true' if v is not None else None,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
SELF_CHECK = Capability(
|
||||||
|
href='/selfcheck/vs/0',
|
||||||
|
poll_tier='cold',
|
||||||
|
entities=(
|
||||||
|
SensorDesc(key='selfcheck_status', field='x.com.samsung.da.status',
|
||||||
|
name='Self-check status', icon='mdi:stethoscope',
|
||||||
|
entity_category='diagnostic'),
|
||||||
|
SensorDesc(key='selfcheck_result', field='x.com.samsung.da.result',
|
||||||
|
name='Self-check result', icon='mdi:clipboard-check-outline',
|
||||||
|
entity_category='diagnostic'),
|
||||||
|
# List of error codes from the last self-check; joined for display.
|
||||||
|
# Not every fridge reports the field, hence the exists_fn.
|
||||||
|
SensorDesc(key='selfcheck_error', field='x.com.samsung.da.error',
|
||||||
|
name='Self-check error', icon='mdi:alert-circle-outline',
|
||||||
|
entity_category='diagnostic',
|
||||||
|
exists_fn=lambda rep, resources: (
|
||||||
|
not rep or 'x.com.samsung.da.error' in rep),
|
||||||
|
value_fn=lambda v: (', '.join(v) if v else None) if isinstance(v, list) else v),
|
||||||
|
ButtonDesc(key='selfcheck_start', field='', name='Start self-check',
|
||||||
|
payload='Start', icon='mdi:play-circle-outline',
|
||||||
|
entity_category='diagnostic',
|
||||||
|
write_fn=lambda p, rep, href=None: (
|
||||||
|
['selfcheck', 'vs', '0'], {'x.com.samsung.da.status': p})),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Cross-family bundles, unpacked into every by_type registry's _build([...])
|
||||||
|
# call the same way ignored.IGNORED is (*common.UNIVERSAL / *common.POWER).
|
||||||
|
# discover() only binds a capability whose href is actually present in a
|
||||||
|
# given device's resource dump, so listing one here for a family that
|
||||||
|
# doesn't expose the href is a no-op, not a phantom entity -- see the
|
||||||
|
# adding-device-support skill's coverage-discipline section.
|
||||||
|
#
|
||||||
|
# UNIVERSAL holds every capability with no known family that both (a) has
|
||||||
|
# the href and (b) needs to model it some other way -- broadening one of
|
||||||
|
# these to a new family is a safe, harmless guess (issue #40's AI energy
|
||||||
|
# level: 2 of 6 families confirmed, blanket-added everywhere else).
|
||||||
|
#
|
||||||
|
# POWER is kept separate -- airconditioner is the one family that opts out
|
||||||
|
# of it. Canonical reason (see by_type/airconditioner.py and its test for
|
||||||
|
# pointers back here, not restatements): AC's climate entity already owns
|
||||||
|
# /power/0 and /power/vs/0 via bare, no-entity Capability objects
|
||||||
|
# (airconditioner.COVERAGE), and a second, real POWER_GENERIC/
|
||||||
|
# POWER_VS_FALLBACK cap on the same href would make _build() raise (a href
|
||||||
|
# with >1 cap must have every cap discriminated by rt_filter/match_fn, and
|
||||||
|
# the bare COVERAGE cap has neither). Kids-lock/remote-control don't have
|
||||||
|
# this conflict -- no AC dump has ever reported those hrefs -- so they stay
|
||||||
|
# in UNIVERSAL.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
UNIVERSAL = (
|
||||||
|
ALARMS,
|
||||||
|
ENERGY_METER,
|
||||||
|
FIRMWARE_UPDATE,
|
||||||
|
SELF_CHECK,
|
||||||
|
AI_ENERGY_LEVEL,
|
||||||
|
KIDS_LOCK_GENERIC,
|
||||||
|
KIDS_LOCK_VS_FALLBACK,
|
||||||
|
REMOTE_CONTROL_GENERIC,
|
||||||
|
REMOTE_CONTROL_VS_FALLBACK,
|
||||||
|
)
|
||||||
|
|
||||||
|
POWER = (
|
||||||
|
POWER_GENERIC,
|
||||||
|
POWER_VS_FALLBACK,
|
||||||
|
)
|
||||||
@@ -8,7 +8,7 @@ wash, auto release dry) are read locally here.
|
|||||||
"""
|
"""
|
||||||
from ..capability import Capability
|
from ..capability import Capability
|
||||||
from ..entities import ButtonDesc, SelectDesc, SensorDesc, SwitchDesc
|
from ..entities import ButtonDesc, SelectDesc, SensorDesc, SwitchDesc
|
||||||
from .laundry import cycle_select, option_value, replace_in_options
|
from .laundry import bool_option_switch, cycle_select
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# /dishwasher/vs/0 — cycle wash/dry settings
|
# /dishwasher/vs/0 — cycle wash/dry settings
|
||||||
@@ -35,49 +35,20 @@ DISHWASHER_SETTINGS = Capability(
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# /course/vs/0 — cycle selection (shared laundry.cycle_select) plus the
|
# /course/vs/0 — cycle selection (shared laundry.cycle_select) plus the
|
||||||
# dishwasher-only StormWashZone / AutoDoorRelease toggles that ride in the
|
# dishwasher-only StormWashZone / AutoDoorRelease toggles that ride in the
|
||||||
# same options array. Course display names live in translations under
|
# same options array (shared laundry.bool_option_switch, same options[]
|
||||||
|
# boolean-toggle contract washer's bubble-soak/pre-wash/intensive switches
|
||||||
|
# use). Course display names live in translations under
|
||||||
# entity.select.dishwasher_cycle (see laundry.cycle_select).
|
# entity.select.dishwasher_cycle (see laundry.cycle_select).
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def _storm_wash_write(p, rep, href=None):
|
|
||||||
if p not in ('On', 'Off'):
|
|
||||||
return None
|
|
||||||
opts = list(rep.get('x.com.samsung.da.options') or [])
|
|
||||||
if not opts:
|
|
||||||
return None
|
|
||||||
return ['course', 'vs', '0'], {
|
|
||||||
'x.com.samsung.da.options': replace_in_options(opts, 'StormWashZone', p),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _auto_release_write(p, rep, href=None):
|
|
||||||
if p not in ('On', 'Off'):
|
|
||||||
return None
|
|
||||||
opts = list(rep.get('x.com.samsung.da.options') or [])
|
|
||||||
if not opts:
|
|
||||||
return None
|
|
||||||
return ['course', 'vs', '0'], {
|
|
||||||
'x.com.samsung.da.options': replace_in_options(opts, 'AutoDoorRelease', p),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
CYCLE_OPTIONS = Capability(
|
CYCLE_OPTIONS = Capability(
|
||||||
href='/course/vs/0',
|
href='/course/vs/0',
|
||||||
entities=(
|
entities=(
|
||||||
cycle_select(translation_key='dishwasher_cycle', icon='mdi:dishwasher'),
|
cycle_select(translation_key='dishwasher_cycle', icon='mdi:dishwasher'),
|
||||||
SwitchDesc(key='storm_wash', name='Storm Wash+', icon='mdi:weather-lightning-rainy',
|
bool_option_switch('storm_wash', 'Storm Wash+', 'mdi:weather-lightning-rainy',
|
||||||
rep_fn=lambda rep: option_value(
|
'StormWashZone'),
|
||||||
rep.get('x.com.samsung.da.options'), 'StormWashZone') == 'On',
|
bool_option_switch('auto_release_dry', 'Auto release dry', 'mdi:door-open',
|
||||||
write_fn=_storm_wash_write),
|
'AutoDoorRelease', gate_on_presence=True),
|
||||||
SwitchDesc(key='auto_release_dry', name='Auto release dry', icon='mdi:door-open',
|
|
||||||
exists_fn=lambda rep, resources: any(
|
|
||||||
isinstance(o, str) and o.startswith('AutoDoorRelease_')
|
|
||||||
for o in (rep.get('x.com.samsung.da.options') or [])
|
|
||||||
),
|
|
||||||
rep_fn=lambda rep: option_value(
|
|
||||||
rep.get('x.com.samsung.da.options'), 'AutoDoorRelease') == 'On',
|
|
||||||
write_fn=_auto_release_write),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import datetime
|
|||||||
|
|
||||||
from ..capability import Capability
|
from ..capability import Capability
|
||||||
from ..entities import (
|
from ..entities import (
|
||||||
BinarySensorDesc, ButtonDesc, NumberDesc, SelectDesc, SensorDesc,
|
BinarySensorDesc, NumberDesc, SelectDesc, SensorDesc,
|
||||||
SwitchDesc, TimeDesc,
|
SwitchDesc, TimeDesc,
|
||||||
)
|
)
|
||||||
from .common import normalize_temp_unit
|
from .common import normalize_temp_unit
|
||||||
@@ -100,6 +100,12 @@ ICEMAKER_NIGHTTIME = Capability(
|
|||||||
# Icemaker (generic — covers /icemaker/one/vs/0, /icemaker/two/vs/0)
|
# 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/status/vs/0 is kept as exact-href cap and binds first.
|
||||||
# /icemaker/nighttime/vs/0 is excluded by match_fn (lacks iceMaker.state).
|
# /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):
|
def _icemaker_write(field):
|
||||||
@@ -112,6 +118,7 @@ ICEMAKER_GENERIC = Capability(
|
|||||||
href=None,
|
href=None,
|
||||||
href_prefix='/icemaker/',
|
href_prefix='/icemaker/',
|
||||||
match_fn=lambda rep, resources: 'x.com.samsung.da.iceMaker.state' in rep,
|
match_fn=lambda rep, resources: 'x.com.samsung.da.iceMaker.state' in rep,
|
||||||
|
name_field='x.com.samsung.da.iceMaker.name',
|
||||||
poll_tier='warm',
|
poll_tier='warm',
|
||||||
entities=(
|
entities=(
|
||||||
SensorDesc(key='making_status',
|
SensorDesc(key='making_status',
|
||||||
@@ -228,36 +235,6 @@ DEFROST_BLOCK_STATUS = Capability(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Self-check diagnostic
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
SELF_CHECK = Capability(
|
|
||||||
href='/selfcheck/vs/0',
|
|
||||||
poll_tier='cold',
|
|
||||||
entities=(
|
|
||||||
SensorDesc(key='selfcheck_status', field='x.com.samsung.da.status',
|
|
||||||
name='Self-check status', icon='mdi:stethoscope',
|
|
||||||
entity_category='diagnostic'),
|
|
||||||
SensorDesc(key='selfcheck_result', field='x.com.samsung.da.result',
|
|
||||||
name='Self-check result', icon='mdi:clipboard-check-outline',
|
|
||||||
entity_category='diagnostic'),
|
|
||||||
# List of error codes from the last self-check; joined for display.
|
|
||||||
# Not every fridge reports the field, hence the exists_fn.
|
|
||||||
SensorDesc(key='selfcheck_error', field='x.com.samsung.da.error',
|
|
||||||
name='Self-check error', icon='mdi:alert-circle-outline',
|
|
||||||
entity_category='diagnostic',
|
|
||||||
exists_fn=lambda rep, resources: (
|
|
||||||
not rep or 'x.com.samsung.da.error' in rep),
|
|
||||||
value_fn=lambda v: (', '.join(v) if v else None) if isinstance(v, list) else v),
|
|
||||||
ButtonDesc(key='selfcheck_start', field='', name='Start self-check',
|
|
||||||
payload='Start', icon='mdi:play-circle-outline',
|
|
||||||
entity_category='diagnostic',
|
|
||||||
write_fn=lambda p, rep, href=None: (
|
|
||||||
['selfcheck', 'vs', '0'], {'x.com.samsung.da.status': p})),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Refrigeration modes (rapid cooling)
|
# Refrigeration modes (rapid cooling)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -588,52 +565,6 @@ FLEX_ZONE = Capability(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# AI energy-saving level
|
|
||||||
#
|
|
||||||
# supportedAiLevel is a single-entry list ('1' only) on some hardware, where
|
|
||||||
# a select would offer no real choice — gated to >1 supported level.
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
AI_ENERGY_LEVEL = Capability(
|
|
||||||
href='/energy/ailevel/vs/0',
|
|
||||||
poll_tier='cold',
|
|
||||||
entities=(
|
|
||||||
# Only levels '1'/'2' have translated labels in strings.json/translations;
|
|
||||||
# a higher supportedAiLevel entry renders as its raw value until added.
|
|
||||||
SelectDesc(key='ai_energy_level', field='aiLevel',
|
|
||||||
name='AI energy level', icon='mdi:leaf',
|
|
||||||
translation_key='ai_energy_level',
|
|
||||||
entity_category='config',
|
|
||||||
options_field='supportedAiLevel',
|
|
||||||
exists_fn=lambda rep, resources: (
|
|
||||||
not rep or (
|
|
||||||
isinstance((sl := rep.get('supportedAiLevel')), (list, tuple))
|
|
||||||
and len(sl) > 1)),
|
|
||||||
write_fn=lambda p, rep, href=None: (
|
|
||||||
['energy', 'ailevel', 'vs', '0'], {'aiLevel': p})),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Firmware update
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
FIRMWARE_UPDATE = Capability(
|
|
||||||
href='/otninformation/vs/0',
|
|
||||||
poll_tier='cold',
|
|
||||||
entities=(
|
|
||||||
BinarySensorDesc(
|
|
||||||
key='firmware_update',
|
|
||||||
field='x.com.samsung.da.newVersionAvailable',
|
|
||||||
name='Firmware update available',
|
|
||||||
device_class='update',
|
|
||||||
entity_category='diagnostic',
|
|
||||||
value_fn=lambda v: str(v).lower() == 'true' if v is not None else None,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Generic door pattern capability (href=None — use as pattern_cap only)
|
# Generic door pattern capability (href=None — use as pattern_cap only)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -217,6 +217,59 @@ def cycle_select(*, translation_key, icon):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Plain boolean toggles over /course/vs/0's options[] array: a
|
||||||
|
# '<prefix>_On'/'<prefix>_Off' token, read-modify-written the same way as
|
||||||
|
# the 'Course' token above. Shared by washer (bubble soak, pre-wash,
|
||||||
|
# intensive -- issue #22) and dishwasher (storm wash, auto release dry) --
|
||||||
|
# both families ride this exact contract, just with different prefixes and
|
||||||
|
# different presence/validation needs on top.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def bool_option_write(prefix):
|
||||||
|
def write(p, rep, href=None):
|
||||||
|
if p not in ('On', 'Off'):
|
||||||
|
return None
|
||||||
|
opts = list(rep.get('x.com.samsung.da.options') or [])
|
||||||
|
if not opts:
|
||||||
|
return None
|
||||||
|
return ['course', 'vs', '0'], {
|
||||||
|
'x.com.samsung.da.options': replace_in_options(opts, prefix, p),
|
||||||
|
}
|
||||||
|
return write
|
||||||
|
|
||||||
|
|
||||||
|
def bool_option_value(prefix):
|
||||||
|
return lambda rep: option_value(rep.get('x.com.samsung.da.options'), prefix) == 'On'
|
||||||
|
|
||||||
|
|
||||||
|
def bool_option_exists(prefix):
|
||||||
|
return lambda rep, resources: option_value(
|
||||||
|
rep.get('x.com.samsung.da.options'), prefix) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def bool_option_switch(key, name, icon, prefix, *, entity_category=None,
|
||||||
|
gate_on_presence=False, validate_fn=None):
|
||||||
|
"""A SwitchDesc over a '<prefix>_On'/'<prefix>_Off' options[] token.
|
||||||
|
|
||||||
|
gate_on_presence self-gates the entity off on models that never report
|
||||||
|
the token at all (washer's bubble soak/pre-wash/intensive); leave False
|
||||||
|
for a toggle every device in the family reports (dishwasher's storm
|
||||||
|
wash). validate_fn is passed straight through to SwitchDesc for callers
|
||||||
|
that need to reject a write against live device state (e.g. washer's
|
||||||
|
per-course availability check) -- this factory has no opinion on it and
|
||||||
|
building one, if needed, is the caller's job.
|
||||||
|
"""
|
||||||
|
return SwitchDesc(
|
||||||
|
key=key, name=name, icon=icon, entity_category=entity_category,
|
||||||
|
exists_fn=bool_option_exists(prefix) if gate_on_presence else None,
|
||||||
|
rep_fn=bool_option_value(prefix),
|
||||||
|
write_fn=bool_option_write(prefix),
|
||||||
|
validate_fn=validate_fn,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# /wm/jobbeginingstatus/vs/0 -- the "why did the cycle not start" reason
|
# /wm/jobbeginingstatus/vs/0 -- the "why did the cycle not start" reason
|
||||||
# (e.g. door open, no water). The vendor field is x.com.samsung.da.currentStatus
|
# (e.g. door open, no water). The vendor field is x.com.samsung.da.currentStatus
|
||||||
|
|||||||
@@ -39,6 +39,15 @@ SETPOINT_MIN_C = 30
|
|||||||
SETPOINT_MAX_C = 270
|
SETPOINT_MAX_C = 270
|
||||||
SETPOINT_STEP_C = 5
|
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
|
# Mode options seen on NV7000BS-class. No dump exists so this list is inferred
|
||||||
# from Samsung documentation and firmware observations. The firmware will
|
# from Samsung documentation and firmware observations. The firmware will
|
||||||
# reject unknown modes; missing entries here are a coverage gap, not a bug.
|
# 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)
|
temp = float(p)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
return None
|
return None
|
||||||
temp_i = int(round(temp / SETPOINT_STEP_C) * SETPOINT_STEP_C)
|
min_v, max_v, step_v = _setpoint_bounds(rep)
|
||||||
if not (SETPOINT_MIN_C <= temp_i <= SETPOINT_MAX_C):
|
temp_i = int(round(temp / step_v) * step_v)
|
||||||
|
if not (min_v <= temp_i <= max_v):
|
||||||
return None
|
return None
|
||||||
items = rep.get('x.com.samsung.da.items')
|
items = rep.get('x.com.samsung.da.items')
|
||||||
if not items:
|
if not items:
|
||||||
@@ -263,14 +273,24 @@ def _oven_temp_unit(rep):
|
|||||||
same aggregate `/temperatures/vs/0` items[] resource type, which on
|
same aggregate `/temperatures/vs/0` items[] resource type, which on
|
||||||
fridge hardware carries a per-item `x.com.samsung.da.unit` field
|
fridge hardware carries a per-item `x.com.samsung.da.unit` field
|
||||||
('Celsius'/'Fahrenheit') that was previously hardcoded away (issue #7).
|
('Celsius'/'Fahrenheit') that was previously hardcoded away (issue #7).
|
||||||
No live oven dump has surfaced a non-Celsius reading yet, so this keeps
|
Keeps the verified '°C' default when the field is absent (the original
|
||||||
the verified '°C' default when the field is absent, but reads it live
|
NV7000BS-class dump this module was written against), but reads it live
|
||||||
if a device ever reports otherwise."""
|
-- issue #44's range dump is the first to report 'Fahrenheit' here."""
|
||||||
items = rep.get('x.com.samsung.da.items') or []
|
items = rep.get('x.com.samsung.da.items') or []
|
||||||
unit = items[0].get('x.com.samsung.da.unit') if items else None
|
unit = items[0].get('x.com.samsung.da.unit') if items else None
|
||||||
return normalize_temp_unit(unit, default='°C')
|
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(
|
OVEN_SETPOINT = Capability(
|
||||||
href='/temperatures/vs/0',
|
href='/temperatures/vs/0',
|
||||||
poll_tier='hot',
|
poll_tier='hot',
|
||||||
@@ -280,6 +300,9 @@ OVEN_SETPOINT = Capability(
|
|||||||
name='Setpoint', device_class='temperature', unit_fn=_oven_temp_unit,
|
name='Setpoint', device_class='temperature', unit_fn=_oven_temp_unit,
|
||||||
native_min=float(SETPOINT_MIN_C), native_max=float(SETPOINT_MAX_C),
|
native_min=float(SETPOINT_MIN_C), native_max=float(SETPOINT_MAX_C),
|
||||||
step=float(SETPOINT_STEP_C), icon='mdi:thermometer-chevron-up',
|
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(
|
value_fn=lambda items: _int(
|
||||||
(items[0].get('x.com.samsung.da.desired') if items else None)),
|
(items[0].get('x.com.samsung.da.desired') if items else None)),
|
||||||
write_fn=_oven_setpoint_write),
|
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(
|
OVEN_MODE = Capability(
|
||||||
href='/mode/vs/0',
|
href='/mode/vs/0',
|
||||||
poll_tier='warm',
|
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'),
|
||||||
|
),
|
||||||
|
)
|
||||||
@@ -17,10 +17,13 @@ from datetime import datetime, timezone
|
|||||||
|
|
||||||
from ..capability import Capability
|
from ..capability import Capability
|
||||||
from ..entities import BinarySensorDesc, SelectDesc, SensorDesc
|
from ..entities import BinarySensorDesc, SelectDesc, SensorDesc
|
||||||
from .laundry import cycle_select, hex_pairs, option_value, replace_in_options
|
from .laundry import (
|
||||||
|
bool_option_exists, bool_option_switch, cycle_options, cycle_select, hex_pairs, option_value,
|
||||||
|
replace_in_options,
|
||||||
|
)
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Course_XX hex codes. The 23 codes named in strings.json/translations
|
# Course_XX hex codes. 23 of the codes named in strings.json/translations
|
||||||
# under entity.select.washer_cycle.state.<id, lowercased> were captured
|
# under entity.select.washer_cycle.state.<id, lowercased> were captured
|
||||||
# from a live WW90DG6U25LEU4's x.com.samsung.da.editCourseList
|
# from a live WW90DG6U25LEU4's x.com.samsung.da.editCourseList
|
||||||
# (EditCourseList_1C1D211B1E29243328262722202325322F2E30662D8F96), matched
|
# (EditCourseList_1C1D211B1E29243328262722202325322F2E30662D8F96), matched
|
||||||
@@ -35,6 +38,13 @@ from .laundry import cycle_select, hex_pairs, option_value, replace_in_options
|
|||||||
# you'd expect to be pinned (default cycle + maintenance cycle),
|
# you'd expect to be pinned (default cycle + maintenance cycle),
|
||||||
# corroborating the positional match.
|
# corroborating the positional match.
|
||||||
#
|
#
|
||||||
|
# A further 5 codes -- '36' Wash+Dry, '37' Air Wash, '38' Cotton Dry,
|
||||||
|
# '39' Synthetics Dry, and a second, distinct '1F' Intense Cold (not the
|
||||||
|
# same code as '8F' above) -- came from a WD90T654DBN/S1 washer/dryer
|
||||||
|
# combo's editCourseList and were named from that user's app screenshot
|
||||||
|
# (issue #22). Combo units carry their own course set, so these codes
|
||||||
|
# don't imply anything about '1F' on a plain washer.
|
||||||
|
#
|
||||||
# No static fallback list of those codes is kept here, deliberately: other
|
# No static fallback list of those codes is kept here, deliberately: other
|
||||||
# washer models have a different actual course set (a second dump's active
|
# washer models have a different actual course set (a second dump's active
|
||||||
# course, '65', isn't even in the list above; models with 'AI Wash'/'Mixed
|
# course, '65', isn't even in the list above; models with 'AI Wash'/'Mixed
|
||||||
@@ -226,9 +236,58 @@ def _dosing_low(prefix):
|
|||||||
rep.get('x.com.samsung.da.options'), prefix) not in (None, 'Off')
|
rep.get('x.com.samsung.da.options'), prefix) not in (None, 'Off')
|
||||||
|
|
||||||
|
|
||||||
def _dosing_alarm_exists(prefix):
|
# Bubble soak / pre-wash / intensive-wash toggles, from the same options[]
|
||||||
return lambda rep, resources: option_value(
|
# array (issue #22 follow-up on a WD90T654DBN/S1 combo). Each rides as a
|
||||||
rep.get('x.com.samsung.da.options'), prefix) is not None
|
# plain '<Prefix>_On'/'<Prefix>_Off' token, confirmed by a dump taken with
|
||||||
|
# Bubble Soak switched on in the app (BubbleSoak_On) -- the same On/Off shape
|
||||||
|
# already used by AiOption and KidsLockBypass in this same array, so
|
||||||
|
# PreWashSetting/IntensiveSetting are assumed to follow suit.
|
||||||
|
#
|
||||||
|
# Each also has a differently-named hex-pair availability field that lines up
|
||||||
|
# positionally with editCourseList: BubbleSoakSet, PreWashAvailableSet,
|
||||||
|
# IntensiveAvailableSet. On the reporter's dump (course '30' at position 1 of
|
||||||
|
# 24), all three read 'F0' at that position and the toggle was writable --
|
||||||
|
# and the same dump's earlier state (course '1C' at position 0, 'BubbleSoak
|
||||||
|
# Off') decodes to '00' for that course, matching the app graying the
|
||||||
|
# control out there. 'F0'/'00' is treated as available/unavailable on that
|
||||||
|
# evidence. exists_fn (device-level presence) still only runs once, against
|
||||||
|
# the setup-time snapshot, so it isn't a fit for this per-course check --
|
||||||
|
# validate_fn runs on every write attempt instead (dispatched from
|
||||||
|
# coordinator.async_send_command, ahead of write_fn), rejecting an on-write
|
||||||
|
# for a course whose byte isn't 'F0' with a user-facing error rather than
|
||||||
|
# silently no-opping against the device. The read/write/presence machinery
|
||||||
|
# itself is laundry.bool_option_switch, shared with dishwasher's storm-wash/
|
||||||
|
# auto-release-dry toggles -- only this per-course gating is washer-only, so
|
||||||
|
# it stays here rather than in laundry.py (see laundry.bool_option_switch's
|
||||||
|
# docstring: it takes a prebuilt validate_fn and has no opinion on it).
|
||||||
|
def _bool_option_switch(key, name, icon, prefix, availability_field):
|
||||||
|
def validate(p, rep, resources):
|
||||||
|
"""Reject turning on when the selected course's byte in
|
||||||
|
`availability_field` isn't 'F0'. Turning off is never blocked. Falls
|
||||||
|
back to allowing the write whenever the availability data can't be
|
||||||
|
resolved (unrecognized course, missing/mismatched-length bitmap)
|
||||||
|
rather than guessing -- a false rejection is worse than an
|
||||||
|
occasional no-op write."""
|
||||||
|
if p != 'On':
|
||||||
|
return None
|
||||||
|
opts = rep.get('x.com.samsung.da.options') or []
|
||||||
|
current = option_value(opts, 'Course')
|
||||||
|
courses = cycle_options(resources)
|
||||||
|
if not current or current not in courses:
|
||||||
|
return None
|
||||||
|
raw = option_value(opts, availability_field)
|
||||||
|
if raw is None:
|
||||||
|
return None
|
||||||
|
pairs = hex_pairs(raw)
|
||||||
|
if len(pairs) != len(courses):
|
||||||
|
return None
|
||||||
|
if pairs[courses.index(current)] != 'F0':
|
||||||
|
return f"{name} isn't available on the selected cycle."
|
||||||
|
return None
|
||||||
|
|
||||||
|
return bool_option_switch(
|
||||||
|
key, name, icon, prefix,
|
||||||
|
entity_category='config', gate_on_presence=True, validate_fn=validate)
|
||||||
|
|
||||||
|
|
||||||
WASHER_COURSE = Capability(
|
WASHER_COURSE = Capability(
|
||||||
@@ -281,11 +340,17 @@ WASHER_COURSE = Capability(
|
|||||||
write_fn=_level_write('SoftenerLevel2Ctrl')),
|
write_fn=_level_write('SoftenerLevel2Ctrl')),
|
||||||
BinarySensorDesc(key='detergent_low', name='Detergent low',
|
BinarySensorDesc(key='detergent_low', name='Detergent low',
|
||||||
icon='mdi:alert-circle-outline', device_class='problem',
|
icon='mdi:alert-circle-outline', device_class='problem',
|
||||||
exists_fn=_dosing_alarm_exists('DetergentAlarm'),
|
exists_fn=bool_option_exists('DetergentAlarm'),
|
||||||
rep_fn=_dosing_low('DetergentAlarm')),
|
rep_fn=_dosing_low('DetergentAlarm')),
|
||||||
BinarySensorDesc(key='softener_low', name='Softener low',
|
BinarySensorDesc(key='softener_low', name='Softener low',
|
||||||
icon='mdi:alert-circle-outline', device_class='problem',
|
icon='mdi:alert-circle-outline', device_class='problem',
|
||||||
exists_fn=_dosing_alarm_exists('SoftenerAlarm'),
|
exists_fn=bool_option_exists('SoftenerAlarm'),
|
||||||
rep_fn=_dosing_low('SoftenerAlarm')),
|
rep_fn=_dosing_low('SoftenerAlarm')),
|
||||||
|
_bool_option_switch('bubble_soak', 'Bubble soak', 'mdi:chart-bubble',
|
||||||
|
'BubbleSoak', 'BubbleSoakSet'),
|
||||||
|
_bool_option_switch('pre_wash', 'Pre wash', 'mdi:washing-machine',
|
||||||
|
'PreWashSetting', 'PreWashAvailableSet'),
|
||||||
|
_bool_option_switch('intensive', 'Intensive', 'mdi:washing-machine',
|
||||||
|
'IntensiveSetting', 'IntensiveAvailableSet'),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -15,6 +15,11 @@ class Capability:
|
|||||||
rt_filter: Optional[str] = None # bind only if rt_filter in rep.get('rt', ())
|
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
|
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
|
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
|
match_fn: Optional[Callable[[dict, dict], bool]] = None # match_fn(rep, resources) -> bool
|
||||||
# Rare optional hook — only operational-state-style resources use this.
|
# Rare optional hook — only operational-state-style resources use this.
|
||||||
on_observation: Optional[Callable[[dict, dict], None]] = None
|
on_observation: Optional[Callable[[dict, dict], None]] = None
|
||||||
|
|||||||
@@ -27,6 +27,25 @@ class BoundEntity:
|
|||||||
desc: SamsungEntityDescription
|
desc: SamsungEntityDescription
|
||||||
instance: str = ''
|
instance: str = ''
|
||||||
key_override: Optional[str] = None
|
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:
|
def instance_suffix(href: str) -> str:
|
||||||
@@ -37,6 +56,18 @@ def instance_suffix(href: str) -> str:
|
|||||||
return ''
|
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(
|
def discover(
|
||||||
resources: dict[str, dict],
|
resources: dict[str, dict],
|
||||||
registry: dict[str, list[Capability]],
|
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):
|
if cap.match_fn is not None and not cap.match_fn(rep, resources):
|
||||||
continue
|
continue
|
||||||
inst = instance_suffix(href)
|
inst = instance_suffix(href)
|
||||||
for desc in cap.entities:
|
out.extend(_bind(cap, href, inst, _instance_name(cap, rep)))
|
||||||
out.append(BoundEntity(href=href, capability=cap,
|
|
||||||
desc=desc, instance=inst))
|
|
||||||
matched = True
|
matched = True
|
||||||
|
|
||||||
if matched:
|
if matched:
|
||||||
@@ -78,10 +107,7 @@ def discover(
|
|||||||
# Auto-derive key prefix from href segments (skip digits and 'vs')
|
# 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
|
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']
|
segs = [s for s in src.strip('/').split('/') if s and not s.isdigit() and s != 'vs']
|
||||||
for desc in cap.entities:
|
out.extend(_bind(cap, href, inst, _instance_name(cap, rep), '_'.join(segs)))
|
||||||
key_override = '_'.join(segs) + '_' + desc.key
|
|
||||||
out.append(BoundEntity(href=href, capability=cap, desc=desc,
|
|
||||||
instance=inst, key_override=key_override))
|
|
||||||
matched = True
|
matched = True
|
||||||
break
|
break
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
Frozen dataclasses so the future native HA component can consume them as
|
Frozen dataclasses so the future native HA component can consume them as
|
||||||
EntityDescription subclasses unchanged. Read transforms live in value_fn;
|
EntityDescription subclasses unchanged. Read transforms live in value_fn;
|
||||||
presence gating in exists_fn; write logic in write_fn on command platforms.
|
presence gating in exists_fn; write logic in write_fn on command platforms;
|
||||||
|
pre-write rejection (surfaced to the user, not just logged) in validate_fn
|
||||||
|
where a description declares one.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -10,6 +12,11 @@ from dataclasses import dataclass, field
|
|||||||
from typing import Any, Callable, Optional
|
from typing import Any, Callable, Optional
|
||||||
|
|
||||||
WriteFn = Optional[Callable[[Any, dict], "tuple[list[str], dict] | None"]]
|
WriteFn = Optional[Callable[[Any, dict], "tuple[list[str], dict] | None"]]
|
||||||
|
# (payload, rep, resources) -> a human-readable rejection message, or None to
|
||||||
|
# allow the write. resources is the coordinator's full href->rep snapshot, for
|
||||||
|
# the same cross-resource lookups exists_fn needs (e.g. reading a sibling
|
||||||
|
# href's live option list).
|
||||||
|
ValidateFn = Optional[Callable[[Any, dict, dict], "str | None"]]
|
||||||
|
|
||||||
|
|
||||||
def _identity(v: Any) -> Any:
|
def _identity(v: Any) -> Any:
|
||||||
@@ -61,6 +68,7 @@ class SelectDesc(SamsungEntityDescription):
|
|||||||
class SwitchDesc(SamsungEntityDescription):
|
class SwitchDesc(SamsungEntityDescription):
|
||||||
device_class: Optional[str] = None
|
device_class: Optional[str] = None
|
||||||
write_fn: WriteFn = None
|
write_fn: WriteFn = None
|
||||||
|
validate_fn: ValidateFn = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, kw_only=True)
|
@dataclass(frozen=True, kw_only=True)
|
||||||
@@ -77,6 +85,13 @@ class NumberDesc(SamsungEntityDescription):
|
|||||||
native_min: Optional[float] = None
|
native_min: Optional[float] = None
|
||||||
native_max: Optional[float] = None
|
native_max: Optional[float] = None
|
||||||
step: 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
|
range_field: Optional[str] = None # resource field containing [min, max] list
|
||||||
write_fn: WriteFn = None
|
write_fn: WriteFn = None
|
||||||
|
|
||||||
|
|||||||
@@ -89,7 +89,12 @@
|
|||||||
"66": "Denim",
|
"66": "Denim",
|
||||||
"2d": "Silent Wash",
|
"2d": "Silent Wash",
|
||||||
"8f": "Intense Cold",
|
"8f": "Intense Cold",
|
||||||
"96": "Less Microfiber"
|
"96": "Less Microfiber",
|
||||||
|
"36": "Wash+Dry",
|
||||||
|
"37": "Air Wash",
|
||||||
|
"38": "Cotton Dry",
|
||||||
|
"39": "Synthetics Dry",
|
||||||
|
"1f": "Intense Cold"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dryer_cycle": {
|
"dryer_cycle": {
|
||||||
@@ -143,12 +148,6 @@
|
|||||||
"180": "3 hr",
|
"180": "3 hr",
|
||||||
"240": "4 hr"
|
"240": "4 hr"
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"ai_energy_level": {
|
|
||||||
"state": {
|
|
||||||
"1": "Level 1",
|
|
||||||
"2": "Level 2"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"sensor": {
|
"sensor": {
|
||||||
|
|||||||
@@ -89,7 +89,12 @@
|
|||||||
"66": "Denim",
|
"66": "Denim",
|
||||||
"2d": "Silent Wash",
|
"2d": "Silent Wash",
|
||||||
"8f": "Intense Cold",
|
"8f": "Intense Cold",
|
||||||
"96": "Less Microfiber"
|
"96": "Less Microfiber",
|
||||||
|
"36": "Wash+Dry",
|
||||||
|
"37": "Air Wash",
|
||||||
|
"38": "Cotton Dry",
|
||||||
|
"39": "Synthetics Dry",
|
||||||
|
"1f": "Intense Cold"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dryer_cycle": {
|
"dryer_cycle": {
|
||||||
@@ -143,12 +148,6 @@
|
|||||||
"180": "3 hr",
|
"180": "3 hr",
|
||||||
"240": "4 hr"
|
"240": "4 hr"
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"ai_energy_level": {
|
|
||||||
"state": {
|
|
||||||
"1": "Level 1",
|
|
||||||
"2": "Level 2"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"sensor": {
|
"sensor": {
|
||||||
|
|||||||
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"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"state_keys": [
|
"state_keys": [
|
||||||
|
"ai_energy_level",
|
||||||
"alarm_code",
|
"alarm_code",
|
||||||
"autofill",
|
"autofill",
|
||||||
"cooler_setpoint",
|
"cooler_setpoint",
|
||||||
|
|||||||
Vendored
+4
@@ -1,6 +1,8 @@
|
|||||||
{
|
{
|
||||||
"state_keys": [
|
"state_keys": [
|
||||||
|
"ai_energy_level",
|
||||||
"alarm_code",
|
"alarm_code",
|
||||||
|
"bubble_soak",
|
||||||
"buzzer_sound",
|
"buzzer_sound",
|
||||||
"child_lock",
|
"child_lock",
|
||||||
"cycle",
|
"cycle",
|
||||||
@@ -14,9 +16,11 @@
|
|||||||
"energy_saved_kwh",
|
"energy_saved_kwh",
|
||||||
"finish_time",
|
"finish_time",
|
||||||
"firmware_update",
|
"firmware_update",
|
||||||
|
"intensive",
|
||||||
"job_beginning_status",
|
"job_beginning_status",
|
||||||
"machine_state",
|
"machine_state",
|
||||||
"power_switch",
|
"power_switch",
|
||||||
|
"pre_wash",
|
||||||
"progress",
|
"progress",
|
||||||
"progress_percentage",
|
"progress_percentage",
|
||||||
"remote_control",
|
"remote_control",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"state_keys": [
|
"state_keys": [
|
||||||
"alarm_code",
|
"alarm_code",
|
||||||
|
"bubble_soak",
|
||||||
"child_lock",
|
"child_lock",
|
||||||
"cycle",
|
"cycle",
|
||||||
"cycle_active",
|
"cycle_active",
|
||||||
@@ -12,9 +13,11 @@
|
|||||||
"energy_kwh",
|
"energy_kwh",
|
||||||
"finish_time",
|
"finish_time",
|
||||||
"firmware_update",
|
"firmware_update",
|
||||||
|
"intensive",
|
||||||
"job_beginning_status",
|
"job_beginning_status",
|
||||||
"machine_state",
|
"machine_state",
|
||||||
"power_switch",
|
"power_switch",
|
||||||
|
"pre_wash",
|
||||||
"progress",
|
"progress",
|
||||||
"progress_percentage",
|
"progress_percentage",
|
||||||
"remote_control",
|
"remote_control",
|
||||||
|
|||||||
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Vendored
+15
@@ -44,6 +44,21 @@
|
|||||||
"href": "/energy/consumption/0",
|
"href": "/energy/consumption/0",
|
||||||
"rep": {}
|
"rep": {}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"href": "/energy/ailevel/vs/0",
|
||||||
|
"rep": {
|
||||||
|
"aiLevel": "0",
|
||||||
|
"supportedAiLevel": [
|
||||||
|
"1"
|
||||||
|
],
|
||||||
|
"energySavingInfo": [
|
||||||
|
{
|
||||||
|
"aiLevel": "1",
|
||||||
|
"maxEnergySavingRate": "70"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"href": "/course/vs/0",
|
"href": "/course/vs/0",
|
||||||
"rep": {
|
"rep": {
|
||||||
|
|||||||
@@ -7,12 +7,16 @@ from unittest.mock import AsyncMock, patch
|
|||||||
import cbor2
|
import cbor2
|
||||||
import pytest
|
import pytest
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.exceptions import ServiceValidationError
|
||||||
from homeassistant.helpers import issue_registry as ir
|
from homeassistant.helpers import issue_registry as ir
|
||||||
|
|
||||||
from custom_components.localthings.const import (
|
from custom_components.localthings.const import (
|
||||||
CONF_HOST, DOMAIN, SUMMARY_INTERVAL_S,
|
CONF_HOST, DOMAIN, SUMMARY_INTERVAL_S,
|
||||||
)
|
)
|
||||||
from custom_components.localthings.coordinator import LocalThingsCoordinator
|
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 custom_components.localthings.observe import MODE_OBSERVE, MODE_POLL, PUSH_HEALTH_WINDOW_S
|
||||||
|
|
||||||
from .conftest import ENTRY_DATA, MOCK_SERIAL
|
from .conftest import ENTRY_DATA, MOCK_SERIAL
|
||||||
@@ -634,3 +638,175 @@ async def test_write_marks_href_pending_before_post(
|
|||||||
await coordinator.async_send_command(bound, 5)
|
await coordinator.async_send_command(bound, 5)
|
||||||
|
|
||||||
assert coordinator._observe._settle_until.get('/test/vs/0') is not None
|
assert coordinator._observe._settle_until.get('/test/vs/0') is not None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_send_command_applies_write_optimistically_before_settling(
|
||||||
|
hass: HomeAssistant, mock_entry, mock_coordinator_observe_session
|
||||||
|
) -> None:
|
||||||
|
"""The cache must reflect a write immediately, and stay put against a
|
||||||
|
stale echo for the rest of the settle window -- otherwise
|
||||||
|
mark_write_pending has nothing to protect and just delays the real
|
||||||
|
device confirmation instead, which reads to a user as the write being
|
||||||
|
silently reverted (issue #27)."""
|
||||||
|
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]
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
# The optimistic value is visible right away, without waiting for a
|
||||||
|
# device response.
|
||||||
|
assert coordinator._cache.get('/test/vs/0') == {'value': 5}
|
||||||
|
|
||||||
|
# A stale update racing in behind the write (e.g. a poll/notify that
|
||||||
|
# was already in flight before the PUT) must not clobber it while the
|
||||||
|
# settle window is open.
|
||||||
|
applied = coordinator._observe.apply('/test/vs/0', {'value': 0}, source='observe')
|
||||||
|
assert applied is False
|
||||||
|
assert coordinator._cache.get('/test/vs/0') == {'value': 5}
|
||||||
|
|
||||||
|
|
||||||
|
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}
|
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():
|
def test_apply_drops_update_during_settle_window():
|
||||||
mgr = _manager()
|
mgr = _manager()
|
||||||
mgr.cache.apply_rep('/oven/vs/0', {'a': 1}, source='seed')
|
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(
|
assert common.REMOTE_CONTROL_VS_FALLBACK.match_fn(
|
||||||
{}, {'/remotectrl/0': {}, '/remotectrl/vs/0': {}}) is False
|
{}, {'/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
|
# Energy meter. instantaneousPower clamps negatives to 0, but the constant
|
||||||
@@ -130,3 +138,202 @@ class TestEnergyMeter:
|
|||||||
phantom power sensor (the exists_fn replaces the field-presence gate)."""
|
phantom power sensor (the exists_fn replaces the field-presence gate)."""
|
||||||
pw = next(e for e in common.ENERGY_METER.entities if e.key == 'power_watts')
|
pw = next(e for e in common.ENERGY_METER.entities if e.key == 'power_watts')
|
||||||
assert pw.exists_fn({'x.com.samsung.da.cumulativePower': '5'}, {}) is False
|
assert pw.exists_fn({'x.com.samsung.da.cumulativePower': '5'}, {}) is False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AI energy-saving level. '0' is off; supportedAiLevel lists the additional
|
||||||
|
# level(s) on offer. A single-entry list (issue #21 fridge, issue #40 washer)
|
||||||
|
# is really a binary toggle, so it's exposed as a switch instead of a
|
||||||
|
# one-choice select; multiple entries get a select with '0' synthesized back
|
||||||
|
# in as the explicit off option (supportedAiLevel never lists '0' itself, but
|
||||||
|
# it's a real, observed value of aiLevel).
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _ai_energy_level_desc(cls_name):
|
||||||
|
return next(e for e in common.AI_ENERGY_LEVEL.entities
|
||||||
|
if e.__class__.__name__ == cls_name)
|
||||||
|
|
||||||
|
|
||||||
|
class TestAiEnergyLevelSwitch:
|
||||||
|
def _desc(self):
|
||||||
|
return _ai_energy_level_desc('SwitchDesc')
|
||||||
|
|
||||||
|
def test_href(self):
|
||||||
|
assert common.AI_ENERGY_LEVEL.href == '/energy/ailevel/vs/0'
|
||||||
|
|
||||||
|
def test_shown_only_with_single_supported_level(self):
|
||||||
|
desc = self._desc()
|
||||||
|
assert desc.exists_fn({'aiLevel': '1', 'supportedAiLevel': ['1']}, {}) is True
|
||||||
|
assert desc.exists_fn({'aiLevel': '1', 'supportedAiLevel': ['1', '2']}, {}) is False
|
||||||
|
|
||||||
|
def test_hidden_when_supported_level_is_non_list_scalar(self):
|
||||||
|
"""A stray scalar (e.g. a string) must not be len()-checked as if it
|
||||||
|
were a list -- a 2-char string would otherwise wrongly pass `== 1`
|
||||||
|
style checks."""
|
||||||
|
desc = self._desc()
|
||||||
|
assert desc.exists_fn({'aiLevel': '1', 'supportedAiLevel': '1'}, {}) is False
|
||||||
|
|
||||||
|
def test_hidden_when_supported_level_missing(self):
|
||||||
|
desc = self._desc()
|
||||||
|
assert desc.exists_fn({'aiLevel': '1'}, {}) is False
|
||||||
|
|
||||||
|
def test_hidden_when_supported_level_empty_list(self):
|
||||||
|
desc = self._desc()
|
||||||
|
assert desc.exists_fn({'aiLevel': '0', 'supportedAiLevel': []}, {}) is False
|
||||||
|
|
||||||
|
def test_hidden_on_empty_stub_rep(self):
|
||||||
|
desc = self._desc()
|
||||||
|
assert desc.exists_fn({}, {}) is False
|
||||||
|
|
||||||
|
def test_value_fn(self):
|
||||||
|
desc = self._desc()
|
||||||
|
assert desc.value_fn('0') is False
|
||||||
|
assert desc.value_fn('1') is True
|
||||||
|
|
||||||
|
def test_write_on_uses_the_single_supported_level(self):
|
||||||
|
"""The on-value is whatever the device calls its one level, not a
|
||||||
|
hardcoded '1'."""
|
||||||
|
desc = self._desc()
|
||||||
|
path, body = desc.write_fn('On', {'supportedAiLevel': ['2']})
|
||||||
|
assert path == ['energy', 'ailevel', 'vs', '0']
|
||||||
|
assert body == {'aiLevel': '2'}
|
||||||
|
|
||||||
|
def test_write_off(self):
|
||||||
|
desc = self._desc()
|
||||||
|
path, body = desc.write_fn('Off', {'supportedAiLevel': ['1']})
|
||||||
|
assert body == {'aiLevel': '0'}
|
||||||
|
|
||||||
|
|
||||||
|
class TestAiEnergyLevelSelect:
|
||||||
|
def _desc(self):
|
||||||
|
return _ai_energy_level_desc('SelectDesc')
|
||||||
|
|
||||||
|
def test_shown_only_with_multiple_supported_levels(self):
|
||||||
|
desc = self._desc()
|
||||||
|
assert desc.exists_fn({'aiLevel': '1', 'supportedAiLevel': ['1', '2']}, {}) is True
|
||||||
|
assert desc.exists_fn({'aiLevel': '1', 'supportedAiLevel': ['1']}, {}) is False
|
||||||
|
|
||||||
|
def test_hidden_when_supported_level_is_non_list_scalar(self):
|
||||||
|
desc = self._desc()
|
||||||
|
assert desc.exists_fn({'aiLevel': '1', 'supportedAiLevel': '12'}, {}) is False
|
||||||
|
|
||||||
|
def test_hidden_when_supported_level_missing(self):
|
||||||
|
desc = self._desc()
|
||||||
|
assert desc.exists_fn({'aiLevel': '1'}, {}) is False
|
||||||
|
|
||||||
|
def test_hidden_when_supported_level_empty_list(self):
|
||||||
|
desc = self._desc()
|
||||||
|
assert desc.exists_fn({'aiLevel': '0', 'supportedAiLevel': []}, {}) is False
|
||||||
|
|
||||||
|
def test_no_translation_key(self):
|
||||||
|
"""aiLevel's values are plain digits that render fine untranslated
|
||||||
|
(select.py's _display()) -- no strings.json entry to maintain
|
||||||
|
against an unknown number of future levels."""
|
||||||
|
desc = self._desc()
|
||||||
|
assert desc.translation_key is None
|
||||||
|
|
||||||
|
def test_options_synthesize_off(self):
|
||||||
|
"""'0' is never in supportedAiLevel but is a real, observed aiLevel
|
||||||
|
value -- synthesized back in as the explicit off option."""
|
||||||
|
desc = self._desc()
|
||||||
|
resources = {'/energy/ailevel/vs/0': {'supportedAiLevel': ['1', '2']}}
|
||||||
|
assert desc.options(resources) == ['0', '1', '2']
|
||||||
|
|
||||||
|
def test_options_empty_when_resource_missing(self):
|
||||||
|
desc = self._desc()
|
||||||
|
assert desc.options({}) == ['0']
|
||||||
|
|
||||||
|
def test_write(self):
|
||||||
|
desc = self._desc()
|
||||||
|
path, body = desc.write_fn('2', {})
|
||||||
|
assert path == ['energy', 'ailevel', 'vs', '0']
|
||||||
|
assert body == {'aiLevel': '2'}
|
||||||
|
|
||||||
|
|
||||||
|
class TestAiEnergyLevelStubDoesNotDecideThePlatform:
|
||||||
|
"""Issue found in review: entity *creation* runs once, against whatever
|
||||||
|
snapshot is current when platforms are set up (see
|
||||||
|
__init__.py's async_config_entry_first_refresh-before-forward-entry-setups
|
||||||
|
ordering), while flatten() re-evaluates exists_fn every poll against live
|
||||||
|
data. Both descriptors share key='ai_energy_level' (see adapter._key), so
|
||||||
|
if a stub carve-out let one of them win at setup time while the other
|
||||||
|
wins once real data lands, flatten() would feed the already-instantiated
|
||||||
|
entity a value shaped for the other platform (e.g. a bool into a Select
|
||||||
|
expecting a string option). Neither side gets a `not rep` carve-out, so
|
||||||
|
an unfetched stub can never win entity creation for either platform --
|
||||||
|
the entity simply doesn't appear until a reload happens with real data,
|
||||||
|
same as any other exists_fn-gated entity in this codebase that's unlucky
|
||||||
|
on first-poll timing, instead of appearing as the wrong widget type."""
|
||||||
|
|
||||||
|
def test_neither_widget_exists_on_empty_stub_rep(self):
|
||||||
|
switch = _ai_energy_level_desc('SwitchDesc')
|
||||||
|
select = _ai_energy_level_desc('SelectDesc')
|
||||||
|
assert switch.exists_fn({}, {}) is False
|
||||||
|
assert select.exists_fn({}, {}) is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestSelfCheckError:
|
||||||
|
"""Self-check diagnostic error list -- surfaced on hardware that reports
|
||||||
|
x.com.samsung.da.error, joined into a single display string."""
|
||||||
|
|
||||||
|
def _desc(self):
|
||||||
|
return next(e for e in common.SELF_CHECK.entities if e.key == 'selfcheck_error')
|
||||||
|
|
||||||
|
def test_exists_when_field_present(self):
|
||||||
|
desc = self._desc()
|
||||||
|
assert desc.exists_fn({'x.com.samsung.da.error': ['DA_ERROR_NONE']}, {}) is True
|
||||||
|
|
||||||
|
def test_does_not_exist_when_field_absent(self):
|
||||||
|
desc = self._desc()
|
||||||
|
assert desc.exists_fn({'x.com.samsung.da.status': 'Ready'}, {}) is False
|
||||||
|
|
||||||
|
def test_exists_for_empty_stub_rep(self):
|
||||||
|
"""An empty {} rep is /device/0's not-yet-fetched-stub carve-out --
|
||||||
|
must be included-for-now, same as ENERGY_METER's fields."""
|
||||||
|
desc = self._desc()
|
||||||
|
assert desc.exists_fn({}, {}) is True
|
||||||
|
|
||||||
|
def test_value_joins_list(self):
|
||||||
|
desc = self._desc()
|
||||||
|
assert desc.value_fn(['E1', 'E2']) == 'E1, E2'
|
||||||
|
|
||||||
|
def test_value_passes_through_scalar(self):
|
||||||
|
desc = self._desc()
|
||||||
|
assert desc.value_fn('DA_ERROR_NONE') == 'DA_ERROR_NONE'
|
||||||
|
|
||||||
|
def test_value_none_for_empty_list(self):
|
||||||
|
"""An empty error list means no value to show -- None (unknown),
|
||||||
|
not an empty string."""
|
||||||
|
desc = self._desc()
|
||||||
|
assert desc.value_fn([]) is None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Cross-family bundles (UNIVERSAL / POWER) -- unpacked into every by_type
|
||||||
|
# registry's _build([...]) call in place of the hand-duplicated capability
|
||||||
|
# lists that used to live there.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestUniversalAndPowerBundles:
|
||||||
|
def test_universal_contains_the_no_conflict_capabilities(self):
|
||||||
|
assert set(common.UNIVERSAL) == {
|
||||||
|
common.ALARMS, common.ENERGY_METER, common.FIRMWARE_UPDATE,
|
||||||
|
common.SELF_CHECK, common.AI_ENERGY_LEVEL,
|
||||||
|
common.KIDS_LOCK_GENERIC, common.KIDS_LOCK_VS_FALLBACK,
|
||||||
|
common.REMOTE_CONTROL_GENERIC, common.REMOTE_CONTROL_VS_FALLBACK,
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_power_kept_separate_for_airconditioners_sake(self):
|
||||||
|
"""See common.POWER's own comment for why airconditioner opts out."""
|
||||||
|
assert set(common.POWER) == {common.POWER_GENERIC, common.POWER_VS_FALLBACK}
|
||||||
|
|
||||||
|
def test_no_overlap_between_the_two_bundles(self):
|
||||||
|
assert not (set(common.UNIVERSAL) & set(common.POWER))
|
||||||
|
|
||||||
|
def test_airconditioner_registry_does_not_include_power(self):
|
||||||
|
from custom_components.localthings.registry.by_type import airconditioner
|
||||||
|
bound_caps = {c for caps in airconditioner.REGISTRY.capabilities.values() for c in caps}
|
||||||
|
assert common.POWER_GENERIC not in bound_caps
|
||||||
|
assert common.POWER_VS_FALLBACK not in bound_caps
|
||||||
@@ -158,6 +158,46 @@ def test_discover_match_fn_decline_is_not_logged_as_gap():
|
|||||||
assert seen == []
|
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():
|
def test_discover_rt_filter_gates_binding():
|
||||||
"""Cap with rt_filter must not bind a rep whose rt list does not match."""
|
"""Cap with rt_filter must not bind a rep whose rt list does not match."""
|
||||||
oven_mode_cap = Capability(
|
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'
|
||||||
@@ -231,84 +231,17 @@ class TestArtik051AndTp2xFixturesHaveCompleteCoverage:
|
|||||||
assert state['flex_zone_mode'] == 'CV_FDR_BEVERAGE'
|
assert state['flex_zone_mode'] == 'CV_FDR_BEVERAGE'
|
||||||
|
|
||||||
|
|
||||||
class TestSelfCheckError:
|
class TestRefrigeratorAiEnergyLevelFixtureCoverage:
|
||||||
"""Self-check diagnostic error list -- surfaced on hardware that reports
|
"""AI energy-saving level (common.AI_ENERGY_LEVEL, see
|
||||||
x.com.samsung.da.error, joined into a single display string."""
|
test_common_capabilities.py) is exercised end-to-end here against the
|
||||||
|
refrigerator fixture/registry.
|
||||||
|
|
||||||
def _desc(self):
|
refrigerator_device.json's supportedAiLevel was extended to two entries
|
||||||
return next(e for e in fridge.SELF_CHECK.entities if e.key == 'selfcheck_error')
|
(['1', '2']) specifically to exercise the select branch -- the real
|
||||||
|
captured TP1X_REF_21K_US dump only ever reports one entry (switch
|
||||||
def test_exists_when_field_present(self):
|
branch), so the select is synthetic-fixture-only for now."""
|
||||||
desc = self._desc()
|
|
||||||
assert desc.exists_fn({'x.com.samsung.da.error': ['DA_ERROR_NONE']}, {}) is True
|
|
||||||
|
|
||||||
def test_does_not_exist_when_field_absent(self):
|
|
||||||
desc = self._desc()
|
|
||||||
assert desc.exists_fn({'x.com.samsung.da.status': 'Ready'}, {}) is False
|
|
||||||
|
|
||||||
def test_exists_for_empty_stub_rep(self):
|
|
||||||
"""An empty {} rep is /device/0's not-yet-fetched-stub carve-out --
|
|
||||||
must be included-for-now, same as ENERGY_METER's fields."""
|
|
||||||
desc = self._desc()
|
|
||||||
assert desc.exists_fn({}, {}) is True
|
|
||||||
|
|
||||||
def test_value_joins_list(self):
|
|
||||||
desc = self._desc()
|
|
||||||
assert desc.value_fn(['E1', 'E2']) == 'E1, E2'
|
|
||||||
|
|
||||||
def test_value_passes_through_scalar(self):
|
|
||||||
desc = self._desc()
|
|
||||||
assert desc.value_fn('DA_ERROR_NONE') == 'DA_ERROR_NONE'
|
|
||||||
|
|
||||||
def test_value_none_for_empty_list(self):
|
|
||||||
"""An empty error list means no value to show -- None (unknown),
|
|
||||||
not an empty string."""
|
|
||||||
desc = self._desc()
|
|
||||||
assert desc.value_fn([]) is None
|
|
||||||
|
|
||||||
|
|
||||||
class TestAiEnergyLevel:
|
|
||||||
"""AI energy-saving level select on /energy/ailevel/vs/0 -- only
|
|
||||||
exposed when the device actually offers more than one level; hardware
|
|
||||||
that only ever reports a single supported level keeps this ignored
|
|
||||||
(see capabilities/ignored.py)."""
|
|
||||||
|
|
||||||
def test_href(self):
|
|
||||||
assert fridge.AI_ENERGY_LEVEL.href == '/energy/ailevel/vs/0'
|
|
||||||
|
|
||||||
def test_hidden_with_single_supported_level(self):
|
|
||||||
desc = fridge.AI_ENERGY_LEVEL.entities[0]
|
|
||||||
assert desc.exists_fn({'aiLevel': '1', 'supportedAiLevel': ['1']}, {}) is False
|
|
||||||
|
|
||||||
def test_shown_with_multiple_supported_levels(self):
|
|
||||||
desc = fridge.AI_ENERGY_LEVEL.entities[0]
|
|
||||||
assert desc.exists_fn(
|
|
||||||
{'aiLevel': '1', 'supportedAiLevel': ['1', '2']}, {}) is True
|
|
||||||
|
|
||||||
def test_exists_for_empty_stub_rep(self):
|
|
||||||
"""An empty {} rep is /device/0's not-yet-fetched-stub carve-out --
|
|
||||||
must be included-for-now, same as ENERGY_METER's fields."""
|
|
||||||
desc = fridge.AI_ENERGY_LEVEL.entities[0]
|
|
||||||
assert desc.exists_fn({}, {}) is True
|
|
||||||
|
|
||||||
def test_hidden_when_supported_level_is_non_list_scalar(self):
|
|
||||||
"""A stray scalar (e.g. a string) must not be len()-checked as if it
|
|
||||||
were a list -- a 5-char string would otherwise wrongly pass `> 1`."""
|
|
||||||
desc = fridge.AI_ENERGY_LEVEL.entities[0]
|
|
||||||
assert desc.exists_fn(
|
|
||||||
{'aiLevel': '1', 'supportedAiLevel': '12'}, {}) is False
|
|
||||||
|
|
||||||
def test_write(self):
|
|
||||||
desc = fridge.AI_ENERGY_LEVEL.entities[0]
|
|
||||||
path, body = desc.write_fn('2', {})
|
|
||||||
assert path == ['energy', 'ailevel', 'vs', '0']
|
|
||||||
assert body == {'aiLevel': '2'}
|
|
||||||
|
|
||||||
def test_synthetic_fixture_has_complete_coverage(self):
|
def test_synthetic_fixture_has_complete_coverage(self):
|
||||||
"""refrigerator_device.json's supportedAiLevel was extended to two
|
|
||||||
entries (['1', '2']) specifically to exercise this select -- the
|
|
||||||
real captured TP1X_REF_21K_US dump only ever reports one entry, so
|
|
||||||
this capability is synthetic-fixture-only for now."""
|
|
||||||
from custom_components.localthings.registry.adapter import flatten
|
from custom_components.localthings.registry.adapter import flatten
|
||||||
from custom_components.localthings.registry.by_type import refrigerator
|
from custom_components.localthings.registry.by_type import refrigerator
|
||||||
from custom_components.localthings.registry.discovery import discover
|
from custom_components.localthings.registry.discovery import discover
|
||||||
|
|||||||
@@ -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():
|
def test_resources_from_batch_preferred_over_flat():
|
||||||
from tests.conftest import _resources_from_dump
|
from tests.conftest import _resources_from_dump
|
||||||
dump = {
|
dump = {
|
||||||
|
|||||||
@@ -53,6 +53,46 @@ def test_oven_setpoint_rejects_missing_items():
|
|||||||
assert desc.write_fn(200, {}) is None
|
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
|
# 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
|
||||||
@@ -251,6 +251,161 @@ class TestDetergentSoftenerDosing:
|
|||||||
assert self._desc('detergent_low').exists_fn(rep, {}) is True
|
assert self._desc('detergent_low').exists_fn(rep, {}) is True
|
||||||
|
|
||||||
|
|
||||||
|
class TestWashOptionToggles:
|
||||||
|
"""Bubble soak / pre-wash / intensive-wash switches, from the same
|
||||||
|
options[] array as the cycle select (issue #22 follow-up). Confirmed
|
||||||
|
On/Off shape from a dump with Bubble Soak toggled on in the app."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _desc(key):
|
||||||
|
return next(e for e in washer.WASHER_COURSE.entities if e.key == key)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _keys():
|
||||||
|
return ('bubble_soak', 'pre_wash', 'intensive')
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _prefix(key):
|
||||||
|
return {'bubble_soak': 'BubbleSoak',
|
||||||
|
'pre_wash': 'PreWashSetting',
|
||||||
|
'intensive': 'IntensiveSetting'}[key]
|
||||||
|
|
||||||
|
def test_exists_only_when_field_present(self):
|
||||||
|
for key in self._keys():
|
||||||
|
desc = self._desc(key)
|
||||||
|
assert desc.exists_fn({'x.com.samsung.da.options': []}, {}) is False
|
||||||
|
prefix = self._prefix(key)
|
||||||
|
rep = {'x.com.samsung.da.options': [f'{prefix}_Off']}
|
||||||
|
assert desc.exists_fn(rep, {}) is True
|
||||||
|
|
||||||
|
def test_reads_off(self):
|
||||||
|
for key in self._keys():
|
||||||
|
prefix = self._prefix(key)
|
||||||
|
rep = {'x.com.samsung.da.options': [f'{prefix}_Off']}
|
||||||
|
assert self._desc(key).rep_fn(rep) is False
|
||||||
|
|
||||||
|
def test_reads_on(self):
|
||||||
|
for key in self._keys():
|
||||||
|
prefix = self._prefix(key)
|
||||||
|
rep = {'x.com.samsung.da.options': [f'{prefix}_On']}
|
||||||
|
assert self._desc(key).rep_fn(rep) is True
|
||||||
|
|
||||||
|
def test_write_on_and_off(self):
|
||||||
|
"""write_fn receives the same 'On'/'Off' string switch.py sends
|
||||||
|
(not a bool) -- covers a bug where an earlier `'On' if p else 'Off'`
|
||||||
|
implementation always wrote 'On', since any non-empty string
|
||||||
|
(including 'Off') is truthy."""
|
||||||
|
for key in self._keys():
|
||||||
|
prefix = self._prefix(key)
|
||||||
|
rep = {'x.com.samsung.da.options': [f'{prefix}_Off', 'GMT_02']}
|
||||||
|
path, body = self._desc(key).write_fn('On', rep)
|
||||||
|
assert path == ['course', 'vs', '0']
|
||||||
|
assert f'{prefix}_On' in body['x.com.samsung.da.options']
|
||||||
|
assert 'GMT_02' in body['x.com.samsung.da.options']
|
||||||
|
|
||||||
|
rep = {'x.com.samsung.da.options': [f'{prefix}_On']}
|
||||||
|
path, body = self._desc(key).write_fn('Off', rep)
|
||||||
|
assert f'{prefix}_Off' in body['x.com.samsung.da.options']
|
||||||
|
assert f'{prefix}_On' not in body['x.com.samsung.da.options']
|
||||||
|
|
||||||
|
def test_write_rejects_non_on_off_payload(self):
|
||||||
|
for key in self._keys():
|
||||||
|
prefix = self._prefix(key)
|
||||||
|
rep = {'x.com.samsung.da.options': [f'{prefix}_Off']}
|
||||||
|
assert self._desc(key).write_fn('bogus', rep) is None
|
||||||
|
|
||||||
|
|
||||||
|
# editCourseList and availability bitmaps from the reporter's issue #22
|
||||||
|
# follow-up dump (WD90T654DBN/S1, course '30' selected, Bubble Soak just
|
||||||
|
# turned on in the app): 24 courses, course '30' at position 1 reads 'F0'
|
||||||
|
# (available) on all three bitmaps; course '1C' at position 0 reads '00' on
|
||||||
|
# BubbleSoakSet (matching the app graying that control out for Eco 40-60).
|
||||||
|
_EDIT_COURSE_RESOURCES = {
|
||||||
|
'/wm/editcourse/vs/0': {
|
||||||
|
'x.com.samsung.da.editCourseList':
|
||||||
|
'EditCourseList_1C301E26361B1D1F253324322022232F212D272838393729',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
_BUBBLE_SOAK_SET = 'BubbleSoakSet_00F000F000F000F0F0F0F00000F000F0F00000F000000000'
|
||||||
|
_PRE_WASH_AVAILABLE_SET = 'PreWashAvailableSet_F0F000F0F0F000F0F0F0F00000F0F0F0F00000F000000000'
|
||||||
|
_INTENSIVE_AVAILABLE_SET = 'IntensiveAvailableSet_F0F000F0F0F000F0F0F0F00000F0F0F0F00000F000000000'
|
||||||
|
|
||||||
|
|
||||||
|
class TestWashOptionToggleValidation:
|
||||||
|
"""validate_fn rejects turning a toggle on for a course whose byte in
|
||||||
|
its availability bitmap isn't 'F0', with a user-facing message switch.py
|
||||||
|
raises as ServiceValidationError -- distinct from write_fn's silent
|
||||||
|
no-op for a malformed payload."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _desc(key):
|
||||||
|
return next(e for e in washer.WASHER_COURSE.entities if e.key == key)
|
||||||
|
|
||||||
|
def test_allowed_on_a_supported_course(self):
|
||||||
|
rep = {'x.com.samsung.da.options': ['Course_30', _BUBBLE_SOAK_SET]}
|
||||||
|
assert self._desc('bubble_soak').validate_fn(
|
||||||
|
'On', rep, _EDIT_COURSE_RESOURCES) is None
|
||||||
|
|
||||||
|
def test_rejected_on_an_unsupported_course(self):
|
||||||
|
rep = {'x.com.samsung.da.options': ['Course_1C', _BUBBLE_SOAK_SET]}
|
||||||
|
msg = self._desc('bubble_soak').validate_fn('On', rep, _EDIT_COURSE_RESOURCES)
|
||||||
|
assert msg == "Bubble soak isn't available on the selected cycle."
|
||||||
|
|
||||||
|
def test_pre_wash_and_intensive_use_their_own_availableset_field(self):
|
||||||
|
rep = {'x.com.samsung.da.options': ['Course_30', _PRE_WASH_AVAILABLE_SET]}
|
||||||
|
assert self._desc('pre_wash').validate_fn(
|
||||||
|
'On', rep, _EDIT_COURSE_RESOURCES) is None
|
||||||
|
rep = {'x.com.samsung.da.options': ['Course_30', _INTENSIVE_AVAILABLE_SET]}
|
||||||
|
assert self._desc('intensive').validate_fn(
|
||||||
|
'On', rep, _EDIT_COURSE_RESOURCES) is None
|
||||||
|
|
||||||
|
def test_turning_off_is_never_blocked(self):
|
||||||
|
rep = {'x.com.samsung.da.options': ['Course_1C', _BUBBLE_SOAK_SET]}
|
||||||
|
assert self._desc('bubble_soak').validate_fn(
|
||||||
|
'Off', rep, _EDIT_COURSE_RESOURCES) is None
|
||||||
|
|
||||||
|
def test_allows_write_when_course_unresolvable(self):
|
||||||
|
"""No editCourseList, no Course_ token, or a bitmap whose length
|
||||||
|
doesn't match editCourseList -- in every case, fail open rather than
|
||||||
|
block a write we can't actually verify."""
|
||||||
|
desc = self._desc('bubble_soak')
|
||||||
|
rep = {'x.com.samsung.da.options': ['Course_1C', _BUBBLE_SOAK_SET]}
|
||||||
|
assert desc.validate_fn('On', rep, {}) is None
|
||||||
|
|
||||||
|
rep = {'x.com.samsung.da.options': [_BUBBLE_SOAK_SET]}
|
||||||
|
assert desc.validate_fn('On', rep, _EDIT_COURSE_RESOURCES) is None
|
||||||
|
|
||||||
|
rep = {'x.com.samsung.da.options': ['Course_1C', 'BubbleSoakSet_00F0']}
|
||||||
|
assert desc.validate_fn('On', rep, _EDIT_COURSE_RESOURCES) is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestAiEnergyLevel:
|
||||||
|
"""Issue #40 -- /energy/ailevel/vs/0 was unbound on a plain washer.
|
||||||
|
|
||||||
|
The capability itself (common.AI_ENERGY_LEVEL) is tested in
|
||||||
|
test_common_capabilities.py; this just confirms it's wired into the
|
||||||
|
washer registry and that the fixture's single-entry supportedAiLevel
|
||||||
|
(['1'], matching the issue's dump) surfaces as a switch, not a select."""
|
||||||
|
|
||||||
|
def test_fixture_has_complete_coverage(self):
|
||||||
|
from custom_components.localthings.registry.adapter import flatten
|
||||||
|
from custom_components.localthings.registry.by_type import washer as washer_registry
|
||||||
|
from custom_components.localthings.registry.discovery import discover
|
||||||
|
from tests.conftest import _load_device
|
||||||
|
|
||||||
|
resources = _load_device('washer')
|
||||||
|
unbound = []
|
||||||
|
bound = discover(
|
||||||
|
resources,
|
||||||
|
washer_registry.REGISTRY.capabilities,
|
||||||
|
washer_registry.REGISTRY.pattern_capabilities,
|
||||||
|
log=unbound.append,
|
||||||
|
)
|
||||||
|
assert unbound == []
|
||||||
|
state = flatten(bound, resources)
|
||||||
|
assert state['ai_energy_level'] is False # aiLevel '0' -- off
|
||||||
|
|
||||||
|
|
||||||
class TestFlexWashAndComboFixturesHaveCompleteCoverage:
|
class TestFlexWashAndComboFixturesHaveCompleteCoverage:
|
||||||
"""FlexWash (issue #19, previously unrecognized entirely) and
|
"""FlexWash (issue #19, previously unrecognized entirely) and
|
||||||
washer/dryer combo (issue #22, dry_level) dumps must both resolve to
|
washer/dryer combo (issue #22, dry_level) dumps must both resolve to
|
||||||
|
|||||||
Reference in new issue
Block a user