Merge remote-tracking branch 'origin/main' into claude/ruff-pyright-ci-stage-gxph0t

This commit is contained in:
Marc Billow committed 2026-08-03 00:20:59 +00:00
commit 18596f23ec
3 files changed
+170 -2

No files matched your search

+32 -1
View File
@@ -5,7 +5,8 @@ from __future__ import annotations
import logging
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
from homeassistant.core import Event, HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr
@@ -21,8 +22,38 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
try:
await coordinator.async_config_entry_first_refresh()
except Exception as err:
# `_poll_once` deliberately leaves the session up on a `TimeoutError`
# (the transfer may just be slow -- see its docstring), so a refresh
# that fails that way ends here with a live, bound UDP socket that
# nothing would ever close. HA retries setup on its own backoff with
# a *new* coordinator, and each device's source port is fixed by
# design (`_local_source_port`), so an abandoned socket squats the
# exact port the next attempt binds -- SO_REUSEADDR lets that bind
# succeed, leaving two sockets racing for the device's datagrams.
await coordinator.async_close()
raise ConfigEntryNotReady(f"Cannot connect to device: {err}") from err
hass.data[DOMAIN][entry.entry_id] = coordinator
# Send the DTLS close_notify on Core shutdown, not just on unload (issue
# #254). `async_close` otherwise only runs via `async_unload_entry`, and
# HA does not unload entries on a plain Core restart -- so a restart left
# the previous run's association orphaned on the appliance, which is what
# makes the *next* run's handshake time out. Complements the fixed source
# port, which covers the unclean-exit case this cannot (see
# `_local_source_port`).
#
# A coroutine listener, not one that spawns its own task: the event bus
# runs it as a hass-tracked job, so the close is awaited by the
# `async_block_till_done()` inside `hass.async_stop`. A detached task
# would likely be cancelled mid-shutdown -- the exact no-close_notify
# case this exists to prevent.
async def _async_close_on_stop(_event: Event) -> None:
await coordinator.async_close()
entry.async_on_unload(
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_close_on_stop)
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
+23 -1
View File
@@ -826,7 +826,21 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
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.
Never defers before the first successful discovery (issue #254).
Deferring is a *mid-session* judgement call — "keep the entities we
already have and try again next cycle" — which is only coherent once
there are entities to keep. Pre-discovery the same exception type
means something else entirely: `_poll_once` calls `_connect_session`,
so `connect()`'s own handshake timeout surfaces here as a
`TimeoutError` too, and that is a dead connection, not a slow
transfer. Deferring it returned an empty dict instead of raising,
which `DataUpdateCoordinator` counts as a successful first refresh —
and since platforms enumerate `bound` exactly once, the entry loaded
with zero entities and stayed that way until a manual reload.
"""
if not self._discovered:
return False
if not isinstance(e, TimeoutError):
return False
if self._observe.mode == MODE_OBSERVE and self._observe.recently_notified():
@@ -893,7 +907,15 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
except Exception as e2:
self._log.error("poll failed after reconnect: %s", e2)
snapshot = self._cache.snapshot()
if snapshot:
# `self._discovered` is the same precondition
# `_defer_reconnect_for` applies (issue #254): returning
# degraded-but-successful data is only meaningful once
# there are bound entities to carry it. Pre-discovery the
# cache happens to always be empty -- every apply() site
# is gated on post-discovery state -- so this arm is
# unreachable then, but that is a non-local accident
# across four call sites, not something to rely on.
if self._discovered and snapshot:
self._log.debug("Full error:", exc_info=e2)
return flatten(self.bound, snapshot)
raise UpdateFailed(f"poll failed after reconnect: {e2}") from e2