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.
This commit is contained in:
Marc Billow
2026-06-30 23:29:31 -05:00
parent eb6e2d34d7
commit 800b113495
3 changed files with 36 additions and 12 deletions
@@ -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
@@ -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'})),
),
)
@@ -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: (