From 800b113495866e084116a90da79d715c9313be96 Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Tue, 30 Jun 2026 23:29:31 -0500 Subject: [PATCH] feat: mutable child lock, remote control, delay start; stale-data fallback on poll failure Child lock and remote control promoted from read-only binary sensors to writable switches. Delay start time promoted from a raw string sensor to a writable TimeEntity. On persistent poll failure (both initial attempt and reconnect), fall back to the last known resource values instead of raising UpdateFailed. This keeps entities available with stale data rather than flooding history with Unavailable gaps whenever the device drops a /device/0 block fetch. UpdateFailed is still raised on the very first poll before any data exists. --- custom_components/localthings/coordinator.py | 4 ++++ .../ocf/registry/capabilities/common.py | 22 ++++++++++++------- .../ocf/registry/capabilities/operational.py | 22 +++++++++++++++---- 3 files changed, 36 insertions(+), 12 deletions(-) diff --git a/custom_components/localthings/coordinator.py b/custom_components/localthings/coordinator.py index d1b6576..44695e4 100644 --- a/custom_components/localthings/coordinator.py +++ b/custom_components/localthings/coordinator.py @@ -246,6 +246,10 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]): try: resources = await self.hass.async_add_executor_job(self._poll_once) except Exception as e2: + _LOGGER.error("poll failed after reconnect: %s", e2) + if self._last_resources: + _LOGGER.debug("Full error:", exc_info=e2) + return flatten(self.bound, self._last_resources) raise UpdateFailed(f"poll failed after reconnect: {e2}") from e2 self._last_resources = resources diff --git a/custom_components/localthings/ocf/registry/capabilities/common.py b/custom_components/localthings/ocf/registry/capabilities/common.py index d449520..867fc04 100644 --- a/custom_components/localthings/ocf/registry/capabilities/common.py +++ b/custom_components/localthings/ocf/registry/capabilities/common.py @@ -11,7 +11,7 @@ against live device dumps: /filter/waterfilter/vs/0 -> x.com.samsung.da.filterUsage / filterStatus """ from ..capability import Capability -from ..entities import BinarySensorDesc, SensorDesc, SwitchDesc +from ..entities import SensorDesc, SwitchDesc def _num(v): @@ -46,19 +46,25 @@ def _active_alarm_codes(items): KIDS_LOCK = Capability( href='/kidslock/vs/0', entities=( - BinarySensorDesc(key='child_lock', field='x.com.samsung.da.kidsLock', - name='Child lock', device_class='lock', - value_fn=lambda v: v != 'Ready'), + SwitchDesc(key='child_lock', field='x.com.samsung.da.kidsLock', + name='Child lock', device_class='lock', + value_fn=lambda v: v != 'Ready', + write_fn=lambda p, rep, href=None: ( + ['kidslock', 'vs', '0'], + {'x.com.samsung.da.kidsLock': 'Enable' if p == 'On' else 'Ready'})), ), ) REMOTE_CONTROL = Capability( href='/remotectrl/vs/0', entities=( - BinarySensorDesc(key='remote_control', - field='x.com.samsung.da.remoteControlEnabled', - name='Remote control', device_class='connectivity', - value_fn=lambda v: str(v).lower() == 'true'), + SwitchDesc(key='remote_control', + field='x.com.samsung.da.remoteControlEnabled', + name='Remote control', device_class='connectivity', + value_fn=lambda v: str(v).lower() == 'true', + write_fn=lambda p, rep, href=None: ( + ['remotectrl', 'vs', '0'], + {'x.com.samsung.da.remoteControlEnabled': 'true' if p == 'On' else 'false'})), ), ) diff --git a/custom_components/localthings/ocf/registry/capabilities/operational.py b/custom_components/localthings/ocf/registry/capabilities/operational.py index 4af8f16..cf70e54 100644 --- a/custom_components/localthings/ocf/registry/capabilities/operational.py +++ b/custom_components/localthings/ocf/registry/capabilities/operational.py @@ -2,10 +2,10 @@ Shared by dryer/dishwasher/oven/washer families. """ -from datetime import datetime, timezone, timedelta +from datetime import datetime, timezone, timedelta, time as dt_time from ..capability import Capability -from ..entities import BinarySensorDesc, ButtonDesc, SensorDesc +from ..entities import BinarySensorDesc, ButtonDesc, SensorDesc, TimeDesc _SAMSUNG_STATE_TO_OCF = { 'Ready': 'idle', 'Run': 'active', 'Running': 'active', @@ -32,6 +32,16 @@ def _active_when(rep): return _SAMSUNG_STATE_TO_OCF.get(rep.get('x.com.samsung.da.state')) == 'active' +def _parse_hms(v): + if not v: + return None + try: + h, m, s = v.split(':') + return dt_time(int(h) % 24, int(m), int(s)) + except Exception: + return None + + def _finish_time(remaining_str): if not remaining_str: return None @@ -66,8 +76,12 @@ OPERATIONAL_STATE = Capability( SensorDesc(key='finish_time', field='x.com.samsung.da.remainingTime', name='Estimated finish', device_class='timestamp', value_fn=_finish_time), - SensorDesc(key='delay_start_time', field='x.com.samsung.da.delayStartTime', - name='Delay start time', icon='mdi:timer-pause'), + TimeDesc(key='delay_start_time', field='x.com.samsung.da.delayStartTime', + name='Delay start', + value_fn=_parse_hms, + write_fn=lambda p, rep, href=None: ( + ['operational', 'state', 'vs', '0'], + {'x.com.samsung.da.delayStartTime': f'{p.hour}:{p.minute:02d}:{p.second:02d}'})), ButtonDesc(key='start', field='', name='Start cycle', payload='Run', icon='mdi:play', write_fn=lambda p, rep, href=None: (