When HA restarts without a clean DTLS close_notify (crash, host reboot), the appliance keeps an orphaned DTLS association keyed to the client's (IP, source port). Reconnecting from a fresh ephemeral port looks like a new peer, so the device holds the orphan until its own timer reaps it, which is 5 to 15 min on always-on appliances (fridges), during which the new session's first reads hang. This is the root cause behind the repeated "DTLS handshake timeout" reconnect storms on always-on devices (#119). Bind a deterministic source port per device so every reconnect re-handshakes over the same 5-tuple, which the device must treat as a rebooted peer and evict the old association for (RFC 6347 §4.2.8). Recovery drops from a device-timer wait to a single handshake. The port must be stable across restarts and unique per device on the HA host (the library socket is unconnected, so a shared source port would cross- deliver datagrams). _local_source_port() uses the host's last IPv4 octet as the offset from DTLS_LOCAL_PORT_BASE (unique on a /24), with a CRC32 fallback for non-IPv4 hosts. Requires smartthings-local >= 0.1.1, which adds DtlsCoapSession(local_port=). The fix is backwards compatible upstream: local_port defaults to None (previous ephemeral-port behaviour). Root-caused and verified upstream in QuiteYellow/SmartThings-Local#14 (bench-verified on oven + dryer, field-verified on an always-on fridge across repeated restarts).
816 lines
39 KiB
Python
816 lines
39 KiB
Python
"""Coordinator for Local Things integration."""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import ipaddress
|
|
import logging
|
|
import threading
|
|
import time
|
|
import zlib
|
|
from datetime import timedelta
|
|
from typing import Any
|
|
|
|
import cbor2
|
|
|
|
from homeassistant.config_entries import ConfigEntry
|
|
from homeassistant.core import HomeAssistant, callback
|
|
from homeassistant.exceptions import ServiceValidationError
|
|
from homeassistant.helpers import issue_registry as ir
|
|
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
|
from homeassistant.helpers.device_registry import DeviceInfo
|
|
|
|
from smartthings_local.protocol.dtls_session import DtlsCoapSession
|
|
from smartthings_local.ocf.state_cache import StateCache
|
|
|
|
from .registry.batch import parse_device0_batch
|
|
from .registry.by_type import for_device, for_device_by_model, for_device_by_resources
|
|
from .registry.capabilities.common import (
|
|
merge_items_field,
|
|
merge_options_field,
|
|
remote_control_enabled,
|
|
remote_control_required_for_write,
|
|
)
|
|
from .registry.discovery import discover, BoundEntity
|
|
from .registry import CAPABILITIES
|
|
from .registry.adapter import flatten
|
|
from .registry.identity import read_identity, DeviceIdentity
|
|
from .observe import ObserveManager, MODE_OBSERVE, MODE_POLL, GRACE_PERIOD_S
|
|
|
|
from .const import (
|
|
DOMAIN, CONF_HOST, CONF_PORT, CONF_LEAF_CERT_PEM, CONF_LEAF_KEY_PEM,
|
|
CONF_BYPASS_REMOTE_CONTROL, DEVICE_SUPPORT_ISSUE_URL, SUMMARY_INTERVAL_S,
|
|
DTLS_LOCAL_PORT_BASE,
|
|
)
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
_SEED_PATH = ['device', '0']
|
|
|
|
class _NoOpDescriptor:
|
|
"""StateCache requires a descriptor with an on_observation hook. This
|
|
integration doesn't use per-capability observation hooks, so this is a
|
|
deliberate no-op, not a placeholder for missing functionality."""
|
|
def on_observation(self, state: dict, href: str, rep: dict) -> None:
|
|
return None
|
|
|
|
|
|
_RECOVERY_RETRY_S = 600.0 # re-attempt observe mode this often while polling
|
|
|
|
|
|
def _local_source_port(host: str) -> int:
|
|
"""Deterministic UDP source port for this device's DTLS socket.
|
|
|
|
Binding the same source port on every (re)connect keeps the client on one
|
|
5-tuple, so the appliance evicts an orphaned session left by a previous run
|
|
(unclean shutdown -> no DTLS close_notify) at handshake time per RFC 6347
|
|
§4.2.8, instead of holding it for 5-15 min while the new session's reads
|
|
hang. See DTLS_LOCAL_PORT_BASE in const.py. Requires smartthings-local
|
|
>= 0.1.1 (the version that added DtlsCoapSession(local_port=...)).
|
|
|
|
The port must be stable across restarts and unique per device on this HA
|
|
host: the library's socket is unconnected (recvfrom), so two devices
|
|
sharing a source port would mis-demux each other's datagrams. For the usual
|
|
dotted-IPv4 host we use the last octet as the offset (unique on a /24);
|
|
anything else folds a stable CRC32 into the same 256-wide window.
|
|
"""
|
|
try:
|
|
offset = int(ipaddress.IPv4Address(host)) & 0xFF
|
|
except (ipaddress.AddressValueError, ValueError):
|
|
offset = zlib.crc32(host.encode()) & 0xFF
|
|
return DTLS_LOCAL_PORT_BASE + offset
|
|
|
|
|
|
def _is_placeholder_serial(serial: str) -> bool:
|
|
"""True for a non-empty serialNum that isn't actually a real identity.
|
|
|
|
The ARTIK051_DONGLE_REF firmware family reports the literal string
|
|
'Nothing(SVC)' for every unit -- non-empty, so the plain `if not
|
|
serial` check below doesn't catch it, and `device_serial` feeds both
|
|
the HA device-registry identifier and every entity's unique_id
|
|
(entity.py), so two such units on the same install silently collide
|
|
and the second one's entities get dropped (issue #83). Mirrors the
|
|
identical helper in config_flow.py's `_probe_and_validate` -- kept
|
|
separate rather than imported to avoid pulling the config-flow module
|
|
into the runtime coordinator's import graph for a two-line check.
|
|
"""
|
|
return serial.strip().lower().startswith('nothing')
|
|
|
|
|
|
class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|
"""Manages one Samsung appliance: session, discovery, polling."""
|
|
|
|
bound: list[BoundEntity]
|
|
device_info: DeviceInfo
|
|
device_serial: str
|
|
|
|
# Class-level knobs (not instance attrs) so tests can shrink real-time
|
|
# delays via `patch.object(LocalThingsCoordinator, ...)` without
|
|
# touching the production defaults these are computed from. Production
|
|
# code always sees these two values; only tests override them.
|
|
_SUBPOLL_STEP_S: float = SUMMARY_INTERVAL_S / 10 # 3.0 s
|
|
_OBSERVE_GRACE_PERIOD_S: float = GRACE_PERIOD_S
|
|
_RECONNECT_PAUSE_S: float = 5.0
|
|
|
|
# A single reconnect is normal appliance-side behavior (see the
|
|
# README's "Known device behavior" section) -- Samsung's firmware
|
|
# drops the DTLS session briefly every now and then, and the
|
|
# coordinator recovering from that on its own isn't something a user
|
|
# needs to see at WARNING. Only escalate once reconnects pile up
|
|
# within a trailing window (issue #119).
|
|
#
|
|
# The window can't be a literal 60s: consecutive reconnect attempts are
|
|
# never closer together than one summary poll interval (SUMMARY_INTERVAL_S,
|
|
# 30s) plus _RECONNECT_PAUSE_S, so at most ~2 can ever land inside a 60s
|
|
# window regardless of how unhealthy the connection is -- a threshold of
|
|
# 5 there could never fire, silently downgrading every reconnect
|
|
# (including a persistently broken one) to INFO forever. 300s/3 instead:
|
|
# reachable under normal polling, and 3 reconnects inside 5 minutes is
|
|
# still a reasonable proxy for the README's "actually broken" case.
|
|
_RECONNECT_WARN_WINDOW_S: float = 300.0
|
|
_RECONNECT_WARN_THRESHOLD: int = 3
|
|
|
|
# A block-level ACK timeout on the summary GET doesn't prove the
|
|
# session is dead (see _poll_once) — require this many in a row
|
|
# before treating it as one. A single slow transfer on an otherwise
|
|
# fine session shouldn't tear down a working OBSERVE subscription.
|
|
_POLL_TIMEOUT_LIMIT: int = 3
|
|
|
|
# Timeouts for the two network round trips a write triggers: the PUT
|
|
# itself (_do_put), then the confirming full /device/0 summary poll
|
|
# async_send_command requests right after (_poll_once). Named here
|
|
# (rather than left as inline literals) so the write-settle window
|
|
# below can be sized to always outlast both — see async_send_command.
|
|
_POST_TIMEOUT_S: float = 8.0
|
|
_POLL_TIMEOUT_S: float = 35.0
|
|
|
|
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
|
|
# Per-device logger (module logger scoped to this device's host) so
|
|
# every log line — including the base DataUpdateCoordinator's own
|
|
# messages and ObserveManager's — identifies which device it's
|
|
# about. A bare module-level logger is shared across every
|
|
# configured device, which makes multi-device logs ambiguous.
|
|
self._log = logging.getLogger(f"{__name__}.{entry.data[CONF_HOST]}")
|
|
super().__init__(
|
|
hass,
|
|
self._log,
|
|
name=f"{DOMAIN}_{entry.data[CONF_HOST]}",
|
|
update_interval=timedelta(seconds=SUMMARY_INTERVAL_S),
|
|
)
|
|
self._entry = entry
|
|
self._session: DtlsCoapSession | None = None
|
|
self._identity: DeviceIdentity | None = None
|
|
self._discovered = False
|
|
self.bound = []
|
|
self._cache = StateCache(_NoOpDescriptor())
|
|
self._cache.set_on_change(self._on_cache_changed)
|
|
self._observe = ObserveManager(self._cache, logger=self._log)
|
|
self._push_pending = False
|
|
self._push_pending_lock = threading.Lock()
|
|
self.device_serial = entry.data[CONF_HOST] # placeholder until first poll
|
|
self.device_info = DeviceInfo(
|
|
identifiers={(DOMAIN, entry.data[CONF_HOST])},
|
|
name=f"Samsung Appliance ({entry.data[CONF_HOST]})",
|
|
manufacturer="Samsung",
|
|
)
|
|
self._session_lock = asyncio.Lock()
|
|
self._subpoll_task: asyncio.Task | None = None
|
|
self._hot_hrefs: list[str] = []
|
|
self._warm_hrefs: list[str] = []
|
|
self.device_type_name: str | None = None
|
|
self.one_ui_version: str = ''
|
|
self._consecutive_poll_timeouts = 0
|
|
self._unbound_hrefs: list[str] = []
|
|
self._reconnect_times: list[float] = []
|
|
|
|
# ------------------------------------------------------------------
|
|
# Session management (all blocking — must run in executor)
|
|
# ------------------------------------------------------------------
|
|
|
|
@property
|
|
def last_resources(self) -> dict:
|
|
return self._cache.snapshot()
|
|
|
|
def resource(self, href: str) -> dict:
|
|
"""A single href's current rep. Cheaper than `last_resources.get(href)`
|
|
for callers that only need one href — `last_resources` copies every
|
|
tracked href's rep to build the snapshot dict, while this is a
|
|
direct O(1) cache lookup."""
|
|
return self._cache.get(href) or {}
|
|
|
|
@property
|
|
def observe_mode(self) -> str:
|
|
return self._observe.mode
|
|
|
|
def _connect_session(self) -> None:
|
|
host = self._entry.data[CONF_HOST]
|
|
port = self._entry.data[CONF_PORT]
|
|
cert_pem = self._entry.data[CONF_LEAF_CERT_PEM]
|
|
key_pem = self._entry.data[CONF_LEAF_KEY_PEM]
|
|
|
|
sess = DtlsCoapSession(host, port, cert_pem=cert_pem, key_pem=key_pem,
|
|
on_notification=self._observe.on_notification,
|
|
local_port=_local_source_port(host))
|
|
sess.connect()
|
|
sess.start_reader()
|
|
self._session = sess
|
|
self._log.debug("DTLS connected to %s:%d", host, port)
|
|
try:
|
|
self._identity = read_identity(sess, None)
|
|
except Exception as e:
|
|
self._log.debug("read_identity failed: %s", e)
|
|
self._identity = None
|
|
|
|
def _close_session(self) -> None:
|
|
sess = self._session
|
|
self._session = None
|
|
if sess is not None:
|
|
try:
|
|
sess.close()
|
|
except Exception:
|
|
pass
|
|
|
|
async def async_close(self) -> None:
|
|
if self._subpoll_task is not None:
|
|
self._subpoll_task.cancel()
|
|
self._subpoll_task = None
|
|
self._observe.close()
|
|
await self.hass.async_add_executor_job(self._close_session)
|
|
|
|
def _on_cache_changed(self, changed: bool, source: str) -> None:
|
|
"""StateCache.set_on_change callback. Runs on whatever thread
|
|
applied the update (DTLS reader thread for observe notifications,
|
|
an executor thread for poll/sweep) — never the event loop, so the
|
|
HA push must be scheduled thread-safely. A sweep/poll cycle can
|
|
call apply_rep for dozens of hrefs in a tight loop; coalesce those
|
|
into a single push instead of one hass.add_job per href."""
|
|
if not changed:
|
|
return
|
|
with self._push_pending_lock:
|
|
if self._push_pending:
|
|
return
|
|
self._push_pending = True
|
|
self.hass.add_job(self._push_cache_snapshot)
|
|
|
|
@callback
|
|
def _push_cache_snapshot(self) -> None:
|
|
with self._push_pending_lock:
|
|
self._push_pending = False
|
|
if self.bound:
|
|
self.async_set_updated_data(flatten(self.bound, self._cache.snapshot()))
|
|
|
|
def _poll_once(self) -> dict[str, dict]:
|
|
"""GET /device/0, return parsed resources. Blocking.
|
|
|
|
`sess.get()` raises `TimeoutError` when one block's ACK doesn't
|
|
arrive in time — the transfer was progressing (earlier blocks
|
|
succeeded) and just didn't finish before the deadline on a slow
|
|
device. That does NOT prove the session is dead, so it's left
|
|
open here; `_async_update_data` decides whether repeated timeouts
|
|
(or a lack of them) warrant a reconnect. Anything else (a
|
|
`ConnectionError` from an explicitly closed/broken session, a bad
|
|
response code) is unambiguous — close immediately.
|
|
"""
|
|
if self._session is None:
|
|
self._connect_session()
|
|
sess = self._session
|
|
try:
|
|
# A slow device can still be mid-transfer (block 8, block 11)
|
|
# when a tighter deadline cuts it off — that's a poll that
|
|
# would have succeeded, not a dead session. 35s gives a slow
|
|
# blockwise transfer room to actually finish instead of
|
|
# generating a TimeoutError every cycle.
|
|
code, payload = sess.get(_SEED_PATH, timeout=self._POLL_TIMEOUT_S)
|
|
except TimeoutError:
|
|
raise
|
|
except Exception as e:
|
|
self._close_session()
|
|
raise RuntimeError(f"poll GET failed: {e}") from e
|
|
if code != 0x45 or not payload:
|
|
self._close_session()
|
|
raise RuntimeError(f"poll: unexpected code {code:#04x}")
|
|
try:
|
|
body = cbor2.loads(payload)
|
|
except Exception as e:
|
|
raise RuntimeError(f"poll cbor decode: {e}") from e
|
|
return parse_device0_batch(body) if isinstance(body, list) else {}
|
|
|
|
def _poll_hrefs_blocking(self, hrefs: list[str]) -> dict[str, dict]:
|
|
"""GET individual hrefs sequentially. Does not reconnect on failure. Blocking."""
|
|
if self._session is None:
|
|
return {}
|
|
results = {}
|
|
first = True
|
|
for href in hrefs:
|
|
if not first:
|
|
self._session.pace()
|
|
first = False
|
|
try:
|
|
path = [s for s in href.strip('/').split('/')]
|
|
code, payload = self._session.get(path, timeout=10.0)
|
|
if code == 0x45 and payload:
|
|
rep = cbor2.loads(payload)
|
|
if isinstance(rep, dict):
|
|
self._observe.apply(href, rep, source='poll')
|
|
results[href] = rep
|
|
except Exception as e:
|
|
self._log.debug("sub-poll %s: %s", href, e)
|
|
return results
|
|
|
|
# ------------------------------------------------------------------
|
|
# Sub-poll loop (runs between summary polls)
|
|
# ------------------------------------------------------------------
|
|
|
|
async def _run_subpolls(self, force: bool = False) -> None:
|
|
"""Poll hot/warm hrefs in the gaps between summary polls. Only
|
|
runs in poll-only mode — in observe-primary mode those hrefs are
|
|
already covered by push notifications — unless `force` is set,
|
|
which this cycle's sweep found disagreeing with the cache on a
|
|
still-live observe session (see log_sweep_discrepancies): a
|
|
bounded, self-limiting fallback for a channel that's gone silent
|
|
without a reconnect, without tearing down subscriptions that
|
|
would otherwise recover on their own once notifies resume."""
|
|
if self._observe.mode == MODE_OBSERVE and not force:
|
|
return
|
|
hot = self._hot_hrefs
|
|
warm = self._warm_hrefs
|
|
if not hot and not warm:
|
|
return
|
|
step = self._SUBPOLL_STEP_S
|
|
for i in range(1, 10): # slots 1..9 (T+3 s … T+27 s)
|
|
await asyncio.sleep(step)
|
|
hrefs = list(hot) + (list(warm) if i % 2 == 0 else [])
|
|
async with self._session_lock:
|
|
try:
|
|
await self.hass.async_add_executor_job(
|
|
self._poll_hrefs_blocking, hrefs
|
|
)
|
|
except Exception as e:
|
|
self._log.debug("sub-poll batch failed: %s", e)
|
|
|
|
# ------------------------------------------------------------------
|
|
# Discovery (runs once on first successful poll)
|
|
# ------------------------------------------------------------------
|
|
|
|
def _run_discovery(self, resources: dict[str, dict]) -> None:
|
|
one_ui = (resources.get('/otninformation/vs/0', {})
|
|
.get('swVersionInfo', {})
|
|
.get('oneUiVersion', ''))
|
|
self.one_ui_version = one_ui
|
|
info = resources.get('/information/vs/0', {})
|
|
reg = for_device(one_ui) if one_ui else None
|
|
if reg is None:
|
|
reg = for_device_by_model(
|
|
info.get('x.com.samsung.da.modelNum', ''),
|
|
info.get('x.com.samsung.da.description', ''),
|
|
)
|
|
if reg is None:
|
|
reg = for_device_by_resources(resources)
|
|
unbound: list[str] = []
|
|
if reg is not None:
|
|
self._log.debug("device type: %s (oneUiVersion=%r)", reg.name, one_ui)
|
|
bound = discover(resources, reg.capabilities, reg.pattern_capabilities,
|
|
log=unbound.append)
|
|
self.device_type_name = reg.name
|
|
else:
|
|
self._log.warning("unknown device type oneUiVersion=%r; using common caps", one_ui)
|
|
bound = discover(resources, CAPABILITIES, log=unbound.append)
|
|
self.device_type_name = None
|
|
self.bound = bound
|
|
self._unbound_hrefs = unbound
|
|
|
|
serial = info.get('x.com.samsung.da.serialNum', '')
|
|
if not serial or _is_placeholder_serial(serial):
|
|
serial = self._entry.data[CONF_HOST]
|
|
self.device_serial = serial
|
|
|
|
ident = self._identity
|
|
device_type = reg.name.replace('_', ' ').title() if reg else 'Appliance'
|
|
model_num = info.get('x.com.samsung.da.modelNum', '')
|
|
model = model_num.split('|', 1)[0] if model_num else (ident.model if ident else '')
|
|
name = f"Samsung {device_type} ({model})" if model else f"Samsung {device_type}"
|
|
mfr = (ident.manufacturer if ident else '') or 'Samsung'
|
|
|
|
self.device_info = DeviceInfo(
|
|
identifiers={(DOMAIN, serial)},
|
|
name=name,
|
|
manufacturer=mfr,
|
|
model=model,
|
|
)
|
|
self._update_coverage_gap_issue(reg is None, unbound, name)
|
|
|
|
hot, warm = set(), set()
|
|
for be in bound:
|
|
tier = be.capability.poll_tier
|
|
if tier == 'hot':
|
|
hot.add(be.href)
|
|
elif tier == 'warm':
|
|
warm.add(be.href)
|
|
self._hot_hrefs = sorted(hot)
|
|
self._warm_hrefs = sorted(warm)
|
|
|
|
self._discovered = True
|
|
self._log.info(
|
|
"discovered %d entities (serial=%s) hot=%s warm=%s",
|
|
len(bound), serial, self._hot_hrefs, self._warm_hrefs,
|
|
)
|
|
|
|
def _update_coverage_gap_issue(
|
|
self, unknown_type: bool, unbound_hrefs: list[str], device_name: str,
|
|
) -> None:
|
|
"""Raise or clear a Repairs issue when capability coverage is incomplete.
|
|
|
|
Fires once, at discovery time, either because the device type itself
|
|
wasn't recognized or because some of its resources didn't bind to
|
|
any capability. Diagnostics (diagnostics.py) is what a user actually
|
|
downloads to help; this just tells them there's something to send.
|
|
"""
|
|
issue_id = f"device_gap_{self._entry.entry_id}"
|
|
if unknown_type or unbound_hrefs:
|
|
ir.async_create_issue(
|
|
self.hass, DOMAIN, issue_id,
|
|
is_fixable=False,
|
|
severity=ir.IssueSeverity.WARNING,
|
|
translation_key="device_gap",
|
|
translation_placeholders={"device_name": device_name},
|
|
learn_more_url=DEVICE_SUPPORT_ISSUE_URL,
|
|
)
|
|
else:
|
|
ir.async_delete_issue(self.hass, DOMAIN, issue_id)
|
|
|
|
async def _attempt_observe_mode(self) -> None:
|
|
"""Called once, right after first discovery. Blocking (sleeps for
|
|
the whole grace period) — must run in an executor."""
|
|
hrefs = self._hot_hrefs + self._warm_hrefs
|
|
if not hrefs:
|
|
return
|
|
if self._session is None:
|
|
# _poll_once already connects on a real poll; this only fires
|
|
# if the session was closed out from under us concurrently.
|
|
await self.hass.async_add_executor_job(self._connect_session)
|
|
sess = self._session
|
|
if sess is None:
|
|
return
|
|
await self.hass.async_add_executor_job(
|
|
self._observe.try_enter_observe_mode, sess, hrefs,
|
|
self._OBSERVE_GRACE_PERIOD_S,
|
|
)
|
|
|
|
async def _maybe_retry_observe_mode(self) -> None:
|
|
"""While in poll-only mode, periodically re-attempt observe mode
|
|
so a device that gains internet access recovers push automatically."""
|
|
if time.monotonic() - self._observe.last_mode_change_ts < _RECOVERY_RETRY_S:
|
|
return
|
|
await self._attempt_observe_mode()
|
|
|
|
def _defer_reconnect_for(self, e: Exception) -> bool:
|
|
"""True if this poll failure should NOT trigger a reconnect this
|
|
cycle.
|
|
|
|
A `TimeoutError` (see `_poll_once`) means one block's ACK didn't
|
|
arrive in time — not that the session is dead. A recent OBSERVE
|
|
notify is direct proof the channel is still live, so always defer
|
|
in that case. Otherwise, defer until `_POLL_TIMEOUT_LIMIT`
|
|
consecutive timeouts have piled up — a single slow transfer is
|
|
normal on a flaky device; a run of them is a real problem. Any
|
|
other exception (a `ConnectionError`, an explicitly closed
|
|
session) is unambiguous and always reconnects immediately.
|
|
"""
|
|
if not isinstance(e, TimeoutError):
|
|
return False
|
|
if self._observe.mode == MODE_OBSERVE and self._observe.recently_notified():
|
|
# Recent push is proof of life — reset the counter too, so
|
|
# timeouts from an earlier quiet stretch don't carry over and
|
|
# trigger a reconnect once the device goes quiet again. The
|
|
# counter should mean "consecutive timeouts with no push
|
|
# activity to vouch for the session," not just "consecutive
|
|
# timeouts" — otherwise an intermittently-active device could
|
|
# still accumulate its way into a false reconnect.
|
|
self._consecutive_poll_timeouts = 0
|
|
return True
|
|
self._consecutive_poll_timeouts += 1
|
|
return self._consecutive_poll_timeouts < self._POLL_TIMEOUT_LIMIT
|
|
|
|
def _reconnect_is_frequent(self) -> bool:
|
|
"""True once this cycle's reconnect is the Nth within the trailing
|
|
warn window -- see _RECONNECT_WARN_WINDOW_S/_RECONNECT_WARN_THRESHOLD."""
|
|
now = time.monotonic()
|
|
self._reconnect_times = [
|
|
t for t in self._reconnect_times
|
|
if now - t < self._RECONNECT_WARN_WINDOW_S
|
|
]
|
|
self._reconnect_times.append(now)
|
|
return len(self._reconnect_times) >= self._RECONNECT_WARN_THRESHOLD
|
|
|
|
# ------------------------------------------------------------------
|
|
# DataUpdateCoordinator hook
|
|
# ------------------------------------------------------------------
|
|
|
|
async def _async_update_data(self) -> dict[str, Any]:
|
|
# Stop any in-flight sub-poll before taking the session lock.
|
|
if self._subpoll_task is not None:
|
|
self._subpoll_task.cancel()
|
|
self._subpoll_task = None
|
|
|
|
just_downgraded_from_observe = False
|
|
async with self._session_lock:
|
|
try:
|
|
resources = await self.hass.async_add_executor_job(self._poll_once)
|
|
self._consecutive_poll_timeouts = 0
|
|
except Exception as e:
|
|
if self._defer_reconnect_for(e):
|
|
self._log.debug(
|
|
"poll failed (%s), not yet treated as session "
|
|
"death; skipping this cycle: %s",
|
|
type(e).__name__, e,
|
|
)
|
|
return flatten(self.bound, self._cache.snapshot())
|
|
self._consecutive_poll_timeouts = 0
|
|
# One reconnect attempt — pause briefly so the device can
|
|
# clean up its DTLS session state before we knock again.
|
|
# A lone reconnect is routine (see the README's "Known
|
|
# device behavior" section); only warn once they're piling
|
|
# up within the trailing window.
|
|
if self._reconnect_is_frequent():
|
|
self._log.warning("poll failed, reconnecting: %s", e)
|
|
else:
|
|
self._log.info("poll failed, reconnecting: %s", e)
|
|
await self.hass.async_add_executor_job(self._close_session)
|
|
await asyncio.sleep(self._RECONNECT_PAUSE_S)
|
|
try:
|
|
resources = await self.hass.async_add_executor_job(self._poll_once)
|
|
except Exception as e2:
|
|
self._log.error("poll failed after reconnect: %s", e2)
|
|
snapshot = self._cache.snapshot()
|
|
if snapshot:
|
|
self._log.debug("Full error:", exc_info=e2)
|
|
return flatten(self.bound, snapshot)
|
|
raise UpdateFailed(f"poll failed after reconnect: {e2}") from e2
|
|
else:
|
|
# The reconnect gave us a brand-new session with zero
|
|
# OBSERVE registrations. If we were in observe mode,
|
|
# that state is now stale — the refresh task is still
|
|
# pinned to the old (closed) session and nothing will
|
|
# ever re-subscribe on the new one. Tear it down and
|
|
# try to resubscribe immediately below rather than
|
|
# waiting for the poll-mode retry timer — that timer
|
|
# exists to throttle devices that never had observe
|
|
# working at all, but a reconnect just proved this
|
|
# session is healthy, so there's no reason to wait.
|
|
if self._observe.mode == MODE_OBSERVE:
|
|
self._log.debug(
|
|
"reconnect while in observe mode; downgrading to "
|
|
"poll and resubscribing on the new session"
|
|
)
|
|
self._observe.downgrade_to_poll()
|
|
just_downgraded_from_observe = True
|
|
|
|
source = 'sweep' if self._discovered else 'poll'
|
|
sweep_mismatch = False
|
|
if self._observe.mode == MODE_OBSERVE:
|
|
# A sweep/cache mismatch never tears down a still-live OBSERVE
|
|
# session (see log_sweep_discrepancies) — the sweep below
|
|
# re-applies the authoritative state to the cache regardless,
|
|
# so there's nothing to correct by downgrading. Only a
|
|
# reconnect (above) proves subscriptions are actually gone.
|
|
# Instead, a mismatch triggers extra hot/warm subpolls this
|
|
# cycle below, so a channel gone silent without a reconnect
|
|
# (e.g. lost internet on an otherwise-live local session)
|
|
# still gets fresher-than-30s data.
|
|
sweep_mismatch = self._observe.log_sweep_discrepancies(resources)
|
|
for href, rep in resources.items():
|
|
self._observe.apply(href, rep, source=source)
|
|
|
|
if not self._discovered:
|
|
self._run_discovery(resources)
|
|
await self._attempt_observe_mode()
|
|
elif just_downgraded_from_observe:
|
|
await self._attempt_observe_mode()
|
|
elif self._observe.mode == MODE_POLL:
|
|
await self._maybe_retry_observe_mode()
|
|
|
|
# Schedule sub-polls for hot/warm hrefs between summary polls
|
|
# (no-op in observe-primary mode unless this cycle's sweep found a
|
|
# mismatch; _run_subpolls checks the mode/force).
|
|
if self._hot_hrefs or self._warm_hrefs:
|
|
self._subpoll_task = self.hass.async_create_task(
|
|
self._run_subpolls(force=sweep_mismatch), name="localthings_subpoll"
|
|
)
|
|
|
|
return flatten(self.bound, self._cache.snapshot())
|
|
|
|
# ------------------------------------------------------------------
|
|
# Command dispatch (called by entity platforms in Task 5)
|
|
# ------------------------------------------------------------------
|
|
|
|
async def async_send_command(self, bound_entity: BoundEntity,
|
|
payload: Any) -> None:
|
|
"""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 -- unless the user has
|
|
opted this device out of it via CONF_BYPASS_REMOTE_CONTROL (issue
|
|
#54: some devices accept certain writes, e.g. a washer's default
|
|
dosing levels, even while reporting remote control off, so the
|
|
block's assumption doesn't hold for every model), or the laundry
|
|
firmware flag isModelSettingWithoutSC declares settings writable
|
|
without Smart Control (cycle start/pause/stop on /operational/state
|
|
still require it)."""
|
|
desc = bound_entity.desc
|
|
write_fn = getattr(desc, 'write_fn', None)
|
|
if write_fn is None:
|
|
return
|
|
href = bound_entity.href
|
|
rep = self._cache.get(href or '') or {}
|
|
resources = self._cache.snapshot()
|
|
bypass_remote_control = self._entry.options.get(CONF_BYPASS_REMOTE_CONTROL, False)
|
|
if (
|
|
not bypass_remote_control
|
|
and remote_control_required_for_write(resources, href or '')
|
|
and not remote_control_enabled(resources)
|
|
):
|
|
raise ServiceValidationError(
|
|
translation_domain=DOMAIN,
|
|
translation_key="remote_control_disabled",
|
|
)
|
|
validate_fn = getattr(desc, 'validate_fn', None)
|
|
if validate_fn is not None:
|
|
error = validate_fn(payload, rep, resources)
|
|
if error:
|
|
raise ServiceValidationError(
|
|
translation_domain=DOMAIN,
|
|
translation_key=error,
|
|
)
|
|
try:
|
|
result = write_fn(payload, rep, href, resources)
|
|
except TypeError:
|
|
result = write_fn(payload, rep, href)
|
|
if result is None:
|
|
self._log.warning("write_fn rejected payload %r for %s", payload, href)
|
|
return
|
|
path_segs, body = result
|
|
|
|
# The write's actual target, not necessarily bound_entity.href. Most
|
|
# descriptors write to the same resource they're bound to, but a
|
|
# composite entity -- the AC's ClimateDesc, bound to /mode/vs/0 --
|
|
# drives writes to several sibling resources via path_segs
|
|
# (/power/0, /temperature/desired/0, /wind/strength/vs/0, ...) that
|
|
# write_fn picks per payload (see airconditioner._climate_write).
|
|
# Applying the optimistic value and settle guard below to
|
|
# bound_entity.href instead of this target protected the wrong
|
|
# resource: /mode/vs/0 got the (nonsensical, wrong-shaped) optimistic
|
|
# merge while the resource the climate entity actually displays from
|
|
# (e.g. /power/0) never got one, so HA kept showing the pre-write
|
|
# state until the next real read of that resource -- the 20-60s lag
|
|
# in issues #17/#53, which survived the earlier optimistic-apply fix
|
|
# (issue #27) because that fix applied to the wrong href too.
|
|
write_href = '/' + '/'.join(path_segs)
|
|
|
|
# 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).
|
|
#
|
|
# settle_s must outlast the PUT and the async_request_refresh()
|
|
# below combined, not just DEFAULT_SETTLE_S's fixed few seconds --
|
|
# that refresh is a full /device/0 summary poll, which
|
|
# _POLL_TIMEOUT_S itself admits can legitimately take tens of
|
|
# seconds on these devices (see _poll_once), and some writes settle
|
|
# on the device itself well after that: issue #9's washer packs
|
|
# cycle/detergent/softener selection into the same /course/vs/0
|
|
# options[] array, and picking a new value there visibly needs a
|
|
# few seconds of internal validation/dispenser movement before the
|
|
# device's own state agrees -- while /washer/vs/0's temperature/
|
|
# spin fields (plain flags, no device-side settling) confirm
|
|
# instantly on the same device. A short fixed window expired while
|
|
# the confirm poll was still in flight (or before the device had
|
|
# caught up internally), so that stale read landed unprotected and
|
|
# reverted the optimistic value, self-correcting again only once a
|
|
# later poll finally saw the real change -- read by the user as the
|
|
# write "reverting, then re-applying itself" a few seconds later.
|
|
#
|
|
# An earlier attempt at this also released the guard early, right
|
|
# after the confirming refresh completed, to avoid shutting out
|
|
# unrelated real updates (another automation, the physical remote)
|
|
# for the rest of settle_s. That was reverted: releasing the guard
|
|
# the moment one round trip finishes doesn't mean the device has
|
|
# actually caught up (exactly the slow-settling case above), and it
|
|
# introduced its own races around overlapping writes to the same
|
|
# href. Simpler and safer to just hold the guard for the full,
|
|
# generously-sized window and let it expire on its own.
|
|
# write_fn bodies that touch x.com.samsung.da.options or
|
|
# x.com.samsung.da.items carry only the changed token(s)/item now
|
|
# (issue #54 for options; the AC vendor temperature write for items --
|
|
# confirmed sufficient on the wire, the device merges the rest itself),
|
|
# not the whole packed array. observe.apply()'s field-level
|
|
# {**cached, **rep} merge doesn't know that -- handed the bare
|
|
# partial value, it would replace the cached field outright and wipe
|
|
# every sibling option/item for the rest of the settle window.
|
|
# Pre-merge it here the same way the device does, so the optimistic
|
|
# cache entry stays complete; the minimal `body` below is still
|
|
# exactly what goes out over the wire.
|
|
optimistic_body = body
|
|
new_options = body.get('x.com.samsung.da.options')
|
|
if isinstance(new_options, list):
|
|
cached_options = (self._cache.get(write_href) or {}).get('x.com.samsung.da.options')
|
|
optimistic_body = {
|
|
**optimistic_body,
|
|
'x.com.samsung.da.options': merge_options_field(cached_options, new_options),
|
|
}
|
|
# Same fact, items[] shape (e.g. airconditioner._climate_write's vendor
|
|
# temperature write, which now carries only {id, desired} -- see that
|
|
# module for the write-side half of this).
|
|
new_items = body.get('x.com.samsung.da.items')
|
|
if isinstance(new_items, list):
|
|
cached_items = (self._cache.get(write_href) or {}).get('x.com.samsung.da.items')
|
|
optimistic_body = {
|
|
**optimistic_body,
|
|
'x.com.samsung.da.items': merge_items_field(cached_items, new_items),
|
|
}
|
|
self._observe.apply(write_href, optimistic_body, source='optimistic')
|
|
self._observe.mark_write_pending(
|
|
write_href, settle_s=self._POST_TIMEOUT_S + self._POLL_TIMEOUT_S
|
|
)
|
|
|
|
def _do_put():
|
|
sess = self._session
|
|
if sess is None:
|
|
raise RuntimeError("no session")
|
|
code, _ = sess.post(path_segs, cbor2.dumps(body), timeout=self._POST_TIMEOUT_S)
|
|
self._log.info("PUT %s → code %#04x", write_href, code)
|
|
|
|
try:
|
|
await self.hass.async_add_executor_job(_do_put)
|
|
except Exception as e:
|
|
self._log.error("command failed for %s: %s", write_href, e)
|
|
else:
|
|
await self.async_request_refresh()
|
|
|
|
# ------------------------------------------------------------------
|
|
# Debug raw write (issue #54): a power-user escape hatch for the
|
|
# options-flow debug panel, letting a user POST an arbitrary partial
|
|
# body to an arbitrary href to pin down device-specific write behavior
|
|
# without waiting on a new release. Deliberately bypasses the
|
|
# remote-control block and every write_fn/validate_fn above -- that's
|
|
# the whole point, so use with care.
|
|
# ------------------------------------------------------------------
|
|
|
|
def _raw_write_blocking(self, path_segs: list[str], body: dict, href: str) -> tuple[int, dict]:
|
|
"""Debug primitive: POST an arbitrary patch, then read the href
|
|
back for ground truth. Blocking -- runs in executor."""
|
|
if self._session is None:
|
|
self._connect_session()
|
|
sess = self._session
|
|
if sess is None:
|
|
raise RuntimeError("no session")
|
|
code, _ = sess.post(path_segs, cbor2.dumps(body), timeout=self._POST_TIMEOUT_S)
|
|
self._log.warning("DEBUG raw write POST %s %r → code %#04x", href, body, code)
|
|
new_rep: dict = {}
|
|
try:
|
|
sess.pace()
|
|
rcode, payload = sess.get(path_segs, timeout=10.0)
|
|
if rcode == 0x45 and payload:
|
|
rep = cbor2.loads(payload)
|
|
if isinstance(rep, dict):
|
|
self._observe.apply(href, rep, source='poll')
|
|
new_rep = rep
|
|
except Exception as e:
|
|
self._log.debug("raw write follow-up read failed: %s", e)
|
|
return code, new_rep
|
|
|
|
async def async_raw_write(self, href: str, body: dict) -> tuple[int, dict]:
|
|
"""Debug-only arbitrary write (issue #54). Bypasses the
|
|
remote-control block and all write_fn/validate_fn logic; sends
|
|
`body` verbatim as a partial-rep PATCH to `href`. Returns
|
|
(coap_code, new_rep) where new_rep is the href's value read back
|
|
right after the write. Used by the options-flow debug panel to
|
|
help users pin down device-specific write behavior without a new
|
|
release."""
|
|
if not isinstance(body, dict) or not body:
|
|
raise ServiceValidationError(
|
|
translation_domain=DOMAIN,
|
|
translation_key="debug_payload_empty",
|
|
)
|
|
path_segs = [s for s in href.strip('/').split('/') if s]
|
|
if not path_segs:
|
|
raise ServiceValidationError(
|
|
translation_domain=DOMAIN,
|
|
translation_key="resource_href_required",
|
|
)
|
|
norm_href = '/' + '/'.join(path_segs)
|
|
async with self._session_lock:
|
|
code, new_rep = await self.hass.async_add_executor_job(
|
|
self._raw_write_blocking, path_segs, body, norm_href
|
|
)
|
|
# Hasten a full summary poll so entities on other resources catch
|
|
# up too -- a debug write can affect siblings, not just its href.
|
|
await self.async_request_refresh()
|
|
return code, new_rep
|