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:
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user