diff --git a/custom_components/localthings/coordinator.py b/custom_components/localthings/coordinator.py index 64445f5..692fa22 100644 --- a/custom_components/localthings/coordinator.py +++ b/custom_components/localthings/coordinator.py @@ -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 diff --git a/tests/localthings/test_coordinator.py b/tests/localthings/test_coordinator.py index 1711150..3338079 100644 --- a/tests/localthings/test_coordinator.py +++ b/tests/localthings/test_coordinator.py @@ -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: