Mark Vistapool entities unavailable while the push connection is down (#180551)

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
fdebrus
2026-08-29 19:35:42 +02:00
committed by GitHub
co-authored by Claude
parent 8fc0cc5eef
commit 6d5256eecc
4 changed files with 126 additions and 4 deletions
@@ -10,7 +10,7 @@ from aioaquarite import (
ResilientPoolSubscription,
)
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN
@@ -41,6 +41,7 @@ class VistapoolDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self.pool_id: str = pool_id
self.pool_name: str = pool_name
self.subscription: ResilientPoolSubscription | None = None
self._push_connected = True
super().__init__(
hass,
@@ -61,17 +62,49 @@ class VistapoolDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
translation_key="update_failed",
) from err
@property
def push_connected(self) -> bool:
"""Whether pool data is still flowing in from the subscription."""
return self._push_connected
async def subscribe(self) -> None:
"""Subscribe to Firestore real-time updates via the library."""
def _on_data(data: dict[str, Any]) -> None:
"""Callback from the Firestore thread; push data to the HA loop."""
self.hass.loop.call_soon_threadsafe(self.async_set_updated_data, data)
self.hass.loop.call_soon_threadsafe(self._async_handle_push, data)
self.subscription = await self.api.subscribe_pool_resilient(
self.pool_id, _on_data
self.pool_id, _on_data, on_health=self._async_on_subscription_health
)
@callback
def _async_handle_push(self, data: dict[str, Any]) -> None:
"""Apply a snapshot; its arrival is what proves the connection is up."""
if not self._push_connected:
self._push_connected = True
_LOGGER.info("Reconnected to %s, entities are available again", self.name)
self.async_set_updated_data(data)
@callback
def _async_on_subscription_health(self, healthy: bool) -> None:
"""Mark entities unavailable while the push connection is down.
Tracked separately from last_update_success: an optimistic update
or a manual refresh sets that flag back to True while the
subscription is still down, and the health callback only fires on
transitions, so it would not correct it. Only an incoming snapshot
clears this.
"""
if healthy or not self._push_connected:
return
self._push_connected = False
_LOGGER.warning(
"Lost the connection to %s, entities are unavailable until it recovers",
self.name,
)
self.async_update_listeners()
@override
async def async_shutdown(self) -> None:
"""Cleanly close the resilient subscription."""
@@ -1,5 +1,7 @@
"""Shared base entity helpers for Vistapool."""
from typing import override
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -24,6 +26,12 @@ class VistapoolEntity(CoordinatorEntity[VistapoolDataUpdateCoordinator]):
sw_version=str(sw_version) if sw_version is not None else None,
)
@property
@override
def available(self) -> bool:
"""Return if entity is available."""
return super().available and self.coordinator.push_connected
@property
def pool_id(self) -> str:
"""Return the pool ID for the entity."""
@@ -39,7 +39,7 @@ rules:
docs-troubleshooting: done
entity-category: done
entity-disabled-by-default: done
entity-unavailable: todo
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates: done
+81
View File
@@ -6,10 +6,13 @@ from unittest.mock import AsyncMock, MagicMock
from aioaquarite import AquariteError, AuthenticationError
from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN
from homeassistant.components.vistapool.const import DOMAIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_ON, STATE_UNAVAILABLE
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from homeassistant.setup import async_setup_component
from .conftest import MOCK_POOL_ID, MOCK_POOL_NAME
@@ -18,6 +21,8 @@ from tests.common import MockConfigEntry
_SECOND_POOL_ID = "ZYXWVU9876543210"
_SECOND_POOL_NAME = "Spa"
_THIRD_POOL_ID = "QQQQQQ1111111111"
_TEMPERATURE_ENTITY = "sensor.my_pool_temperature"
_LIGHT_ENTITY = "light.my_pool_light"
async def test_setup_entry(
@@ -312,6 +317,82 @@ async def test_apply_optimistic_creates_missing_intermediate_dicts(
assert coordinator.data["existing"] == {"nested": {"key": 1}}
async def test_entities_unavailable_while_push_connection_is_down(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_vistapool_client: AsyncMock,
) -> None:
"""Test entities go unavailable when the Firestore subscription drops.
The integration has no polling interval, so without this the last
snapshot would stay on display as if it were still current.
"""
mock_config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert hass.states.get(_TEMPERATURE_ENTITY).state != STATE_UNAVAILABLE
call = mock_vistapool_client.subscribe_pool_resilient.call_args
on_data = call.args[1]
on_health = call.kwargs["on_health"]
on_health(False)
await hass.async_block_till_done()
assert hass.states.get(_TEMPERATURE_ENTITY).state == STATE_UNAVAILABLE
# Only an incoming snapshot proves the connection is back.
on_data({"main": {"temperature": 25}})
await hass.async_block_till_done()
assert hass.states.get(_TEMPERATURE_ENTITY).state == "25.0"
async def test_entities_stay_unavailable_on_local_updates_during_outage(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_vistapool_client: AsyncMock,
) -> None:
"""Test updates that are not push snapshots do not fake availability.
Both an optimistic write and a manual refresh set the coordinator's
success flag, so availability cannot ride on that flag alone.
"""
mock_vistapool_client.fetch_pool_data.return_value = {"light": {"status": 0}}
mock_config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert await async_setup_component(hass, "homeassistant", {})
on_health = mock_vistapool_client.subscribe_pool_resilient.call_args.kwargs[
"on_health"
]
on_health(False)
await hass.async_block_till_done()
assert hass.states.get(_LIGHT_ENTITY).state == STATE_UNAVAILABLE
# An optimistic write updates coordinator data while the push is down.
await hass.services.async_call(
LIGHT_DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: _LIGHT_ENTITY},
blocking=True,
)
await hass.async_block_till_done()
assert hass.states.get(_LIGHT_ENTITY).state == STATE_UNAVAILABLE
# So does a successful manual refresh.
await hass.services.async_call(
"homeassistant",
"update_entity",
{ATTR_ENTITY_ID: _LIGHT_ENTITY},
blocking=True,
)
await hass.async_block_till_done()
assert hass.states.get(_LIGHT_ENTITY).state == STATE_UNAVAILABLE
async def test_unload_entry(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,