Allow non-blocking setup when device is offline (#295)

When a device is offline or unreachable during Home Assistant startup,
 previously raised . HA's built-in
retry mechanism uses exponential backoff up to 15 minutes, which leads to
a poor user experience for local LAN devices.

Catch initial connection errors during  and log a
warning instead of failing setup. This allows platforms to set up and
entities to be created (in an unavailable state), while the coordinator
continues background retry polling.
This commit is contained in:
firstof9@gmail.com
2026-08-15 19:38:20 +00:00
committed by Marc Billow
parent dbc5c55a97
commit 67012c57d7
2 changed files with 14 additions and 29 deletions
+4 -9
View File
@@ -13,7 +13,6 @@ from homeassistant.const import (
)
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.typing import ConfigType
@@ -263,14 +262,10 @@ 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
# (see its docstring), so a refresh failing that way leaves a live,
# bound UDP socket nothing would ever close. HA retries setup with a
# new coordinator, and the source port is fixed by design
# (`_local_source_port`), so an abandoned socket would squat the
# exact port the next attempt binds.
await coordinator.async_close()
raise ConfigEntryNotReady(f"Cannot connect to device: {err}") from err
_LOGGER.warning(
"Initial connection to device failed (%s); starting offline and retrying in background",
err,
)
hass.data[DOMAIN][entry.entry_id] = coordinator
# Send the DTLS close_notify on Core shutdown, not just on unload (issue
+10 -20
View File
@@ -70,8 +70,8 @@ async def test_summary_interval(hass: HomeAssistant, mock_entry, mock_coordinato
assert coordinator.update_interval == timedelta(seconds=SUMMARY_INTERVAL_S)
async def test_update_failed_on_persistent_poll_error(hass: HomeAssistant, mock_entry) -> None:
"""ConfigEntryNotReady raised when poll fails even after reconnect."""
async def test_offline_initial_setup_succeeds(hass: HomeAssistant, mock_entry) -> None:
"""ConfigEntry setup succeeds in offline mode when initial poll fails."""
with (
patch("custom_components.localthings.coordinator.LocalThingsCoordinator._connect_session"),
@@ -84,8 +84,10 @@ async def test_update_failed_on_persistent_poll_error(hass: HomeAssistant, mock_
result = await hass.config_entries.async_setup(mock_entry.entry_id)
await hass.async_block_till_done()
# Setup fails — entry should not be in hass.data
assert not result or mock_entry.entry_id not in hass.data.get(DOMAIN, {})
# Setup succeeds — entry should be in hass.data and state is LOADED
assert result
assert mock_entry.entry_id in hass.data.get(DOMAIN, {})
assert mock_entry.state == ConfigEntryState.LOADED
# ---------------------------------------------------------------------------
@@ -1821,13 +1823,11 @@ async def test_first_refresh_timeout_recovers_via_reconnect(
assert coordinator._consecutive_poll_timeouts == 0
async def test_first_refresh_persistent_timeout_fails_setup(
async def test_first_refresh_persistent_timeout_succeeds_offline(
hass: HomeAssistant, mock_entry
) -> None:
"""When the reconnect times out too, the first refresh must fail so HA
retries on its backoff -- not load an entity-less entry. The session it
left open is closed on the way out (`_poll_once` keeps it up on a
`TimeoutError`, and the source port is fixed per device)."""
"""When initial refresh times out, setup still succeeds in offline mode
and retries in background."""
with (
patch("custom_components.localthings.coordinator.LocalThingsCoordinator._connect_session"),
patch(
@@ -1835,21 +1835,11 @@ async def test_first_refresh_persistent_timeout_fails_setup(
side_effect=_HANDSHAKE_TIMEOUT,
),
patch("custom_components.localthings.coordinator.LocalThingsCoordinator._close_session"),
# Asserted on `async_close` rather than `_close_session`: the
# reconnect path calls the latter on its own, so it is already true
# whether or not setup cleans up after itself.
patch.object(
LocalThingsCoordinator,
"async_close",
autospec=True,
) as mock_async_close,
):
await hass.config_entries.async_setup(mock_entry.entry_id)
await hass.async_block_till_done()
assert mock_entry.state is ConfigEntryState.SETUP_RETRY
assert not hass.states.async_all()
mock_async_close.assert_awaited_once()
assert mock_entry.state is ConfigEntryState.LOADED
async def test_post_discovery_timeout_is_still_deferred(