fix(coordinator): downgrade observe mode when a device stays unreachable

When a poll fails and the immediate reconnect retry fails too,
_async_update_data returned the last-known snapshot as a degraded
success (issue #254) without ever touching observe mode. That's fine
for the data itself, but the connection-mode sensor reads straight
from self._observe.mode, and only the *successful* reconnect branch
ever changed it -- so a device that drops off the network entirely
(air-gapped, powered off, Wi-Fi down) left that sensor reporting
"Push" forever, hours after the session was actually dead (issue
#287).

Downgrade to poll mode on the failure branch too, without attempting
an immediate resubscribe: the reconnect that would normally justify
one just proved there's no live session to subscribe on. Recovery
still happens on its own once the device is reachable again, via the
existing poll-mode retry timer (_maybe_retry_observe_mode).
This commit is contained in:
Marc Billow committed 2026-08-06 00:42:31 +00:00
1 parent 455ed5b27c
commit 252306838d
2 files changed
+65

No files matched your search

@@ -834,6 +834,20 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
resources = await self.hass.async_add_executor_job(self._poll_once)
except Exception as e2:
self._log.error("poll failed after reconnect: %s", e2)
# The reconnect itself proved the old session is gone, so
# any OBSERVE subscription on it is too -- this cycle has
# no live session to resubscribe on, unlike the success
# branch below, so downgrade without setting
# just_downgraded_from_observe (that would attempt a
# resubscribe this same cycle against a session we just
# confirmed is unreachable). Without this, a device that
# drops off the network entirely leaves the connection-mode
# sensor reporting "Push" forever, since only the success
# path below ever changed it (issue #287) -- the poll-mode
# retry timer (_maybe_retry_observe_mode) still re-attempts
# observe once the device is actually reachable again.
if self._observe.mode == MODE_OBSERVE:
self._observe.downgrade_to_poll()
snapshot = self._cache.snapshot()
# Same precondition as _defer_reconnect_for (issue #254):
# degraded-but-successful data only makes sense once
+51
View File
@@ -478,6 +478,57 @@ async def test_reconnect_while_observe_mode_downgrades_to_poll(
assert coordinator._observe.mode == MODE_POLL
async def test_total_poll_failure_downgrades_observe_mode_to_poll(
hass: HomeAssistant, mock_entry, mock_coordinator_observe_session, fridge_resources
) -> None:
"""A device that drops off the network entirely -- both the poll and its
reconnect retry fail -- must not leave the connection-mode sensor
reporting 'Push' forever (issue #287). Only the *successful* reconnect
branch used to touch observe mode (see
test_reconnect_while_observe_mode_downgrades_to_poll); this covers the
branch where the device stays unreachable."""
fake = mock_coordinator_observe_session
await hass.config_entries.async_setup(mock_entry.entry_id)
await hass.async_block_till_done()
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
hrefs = coordinator._hot_hrefs + coordinator._warm_hrefs
fake.notify_on_subscribe = {"notified": True}
entered = await hass.async_add_executor_job(
coordinator._observe.try_enter_observe_mode,
fake,
hrefs,
0.02,
0.8,
)
assert entered is True
assert coordinator.observe_mode == MODE_OBSERVE
last_notify_ts = coordinator._observe._last_notify_ts
assert last_notify_ts is not None
coordinator._observe._last_notify_ts = last_notify_ts - (PUSH_HEALTH_WINDOW_S + 1)
with (
patch(
"custom_components.localthings.coordinator.LocalThingsCoordinator._poll_once",
side_effect=[RuntimeError("connection lost"), RuntimeError("still lost")],
),
patch(
"custom_components.localthings.coordinator.asyncio.sleep",
new=AsyncMock(),
),
):
await coordinator.async_request_refresh()
await hass.async_block_till_done()
# The update still "succeeds" with the last-known snapshot (issue #254's
# degraded-data path) -- but the connection mode must reflect reality
# now, not the stale OBSERVE state from before the outage.
assert coordinator.observe_mode == MODE_POLL
assert coordinator.last_update_success is True
async def test_poll_timeout_skips_reconnect_when_push_is_healthy(
hass: HomeAssistant, mock_entry, mock_coordinator_observe_session
) -> None: