From 67012c57d7a67396ef72886c53ec82596ab39c20 Mon Sep 17 00:00:00 2001 From: "firstof9@gmail.com" Date: Wed, 5 Aug 2026 10:17:50 -0700 Subject: [PATCH] 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. --- custom_components/localthings/__init__.py | 13 +++------- tests/localthings/test_coordinator.py | 30 ++++++++--------------- 2 files changed, 14 insertions(+), 29 deletions(-) diff --git a/custom_components/localthings/__init__.py b/custom_components/localthings/__init__.py index 3b0803f..ce47f79 100644 --- a/custom_components/localthings/__init__.py +++ b/custom_components/localthings/__init__.py @@ -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 diff --git a/tests/localthings/test_coordinator.py b/tests/localthings/test_coordinator.py index 6dfd2d0..82cd159 100644 --- a/tests/localthings/test_coordinator.py +++ b/tests/localthings/test_coordinator.py @@ -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(