Bump aioaquarite to 0.13.0 and drop the Vistapool optimistic layer (#184132)

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
fdebrus
2026-10-04 08:37:21 +02:00
committed by GitHub
co-authored by Claude Fable 5.1
parent 774da58f54
commit 81e7dd9fad
17 changed files with 90 additions and 275 deletions
+12 -9
View File
@@ -1,6 +1,5 @@
"""Vistapool Button entities."""
import asyncio
from typing import override
from aioaquarite import AquariteError
@@ -60,7 +59,8 @@ class VistapoolLEDPulseButton(VistapoolEntity, ButtonEntity):
Mirrors the "Next" button under LED Color in the Vistapool app's
Illumination screen. If the light is on, sends light.status=0, waits a
moment, then light.status=1; the physical LED fixture advances to the
next color on power-on. If the light is off, just turns it on.
next color on power-on. If the light is off, just turns it on. The
library runs the sequence, so the light never shows the intermediate off.
"""
_attr_translation_key = "led_pulse"
@@ -74,18 +74,21 @@ class VistapoolLEDPulseButton(VistapoolEntity, ButtonEntity):
async def async_press(self) -> None:
"""Send a color-advance pulse to the pool LED fixture."""
try:
if self.coordinator.get_value(_LIGHT_STATUS_PATH) in (True, "1"):
if self.coordinator.get_value(_LIGHT_STATUS_PATH) == 1:
await self.coordinator.api.pulse(
self.coordinator.pool_id,
_LIGHT_STATUS_PATH,
0,
1,
_LED_PULSE_DELAY_SECONDS,
)
else:
await self.coordinator.api.set_value(
self.coordinator.pool_id, _LIGHT_STATUS_PATH, 0
self.coordinator.pool_id, _LIGHT_STATUS_PATH, 1
)
await asyncio.sleep(_LED_PULSE_DELAY_SECONDS)
await self.coordinator.api.set_value(
self.coordinator.pool_id, _LIGHT_STATUS_PATH, 1
)
except AquariteError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="set_failed",
translation_placeholders={"entity": self.entity_id},
) from err
self.coordinator.apply_optimistic(_LIGHT_STATUS_PATH, 1)
@@ -68,41 +68,38 @@ class VistapoolDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
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_handle_push, data)
"""Subscribe to Firestore real-time updates via the library.
The library invokes the data callback on the event loop, with
acknowledged writes already reflected, so deliveries are published
as they come.
"""
self.subscription = await self.api.subscribe_pool_resilient(
self.pool_id, _on_data, on_health=self._async_on_subscription_health
self.pool_id,
self.async_set_updated_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.
"""Mirror the push connection state into entity availability.
Tracked separately from last_update_success: an optimistic update
Tracked separately from last_update_success: an acknowledged write
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.
subscription is still down. The library reports healthy only once a
reconnected stream has delivered a consistent snapshot, so data
arriving in between must not fake availability either.
"""
if healthy or not self._push_connected:
if healthy == self._push_connected:
return
self._push_connected = False
_LOGGER.warning(
"Lost the connection to %s, entities are unavailable until it recovers",
self.name,
)
self._push_connected = healthy
if healthy:
_LOGGER.info("Reconnected to %s, entities are available again", self.name)
else:
_LOGGER.warning(
"Lost the connection to %s, entities are unavailable until it recovers",
self.name,
)
self.async_update_listeners()
@override
@@ -116,32 +113,3 @@ class VistapoolDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
def get_value(self, path: str, default: Any = None) -> Any:
"""Get nested data using dot-notation path."""
return AquariteClient.get_value(self.data, path, default)
def apply_optimistic(self, value_path: str, value: Any) -> None:
"""Reflect a just-written value before the Firestore push round-trips.
Hayward's cloud takes several seconds to acknowledge a write back
through Firestore, which would make the UI feel laggy. Writing into
coordinator.data after a successful REST call gives entities instant
feedback; the next snapshot from Firestore overwrites it harmlessly.
"""
self.apply_optimistic_values({value_path: value})
def apply_optimistic_values(self, updates: dict[str, Any]) -> None:
"""Reflect several just-written values as a single update.
Applying them one at a time would publish a state where only part
of the write has landed, which entities derived from more than one
path briefly read as a different value.
"""
for value_path, value in updates.items():
keys = value_path.split(".")
target: dict[str, Any] = self.data
for key in keys[:-1]:
child = target.get(key)
if not isinstance(child, dict):
child = {}
target[key] = child
target = child
target[keys[-1]] = value
self.async_set_updated_data(self.data)
@@ -86,4 +86,3 @@ class VistapoolLight(VistapoolEntity, LightEntity):
translation_key="set_failed",
translation_placeholders={"entity": self.entity_id},
) from err
self.coordinator.apply_optimistic(_VALUE_PATH, value)
@@ -13,5 +13,5 @@
"iot_class": "cloud_push",
"loggers": ["aioaquarite"],
"quality_scale": "gold",
"requirements": ["aioaquarite==0.11.0"]
"requirements": ["aioaquarite==0.13.0"]
}
@@ -260,4 +260,3 @@ class VistapoolNumber(VistapoolEntity, NumberEntity):
translation_key="set_failed",
translation_placeholders={"entity": self.entity_id},
) from err
self.coordinator.apply_optimistic(self.entity_description.value_path, raw)
@@ -77,12 +77,6 @@ rules:
stale-devices: done
# Platinum
async-dependency:
status: todo
comment: >-
Commands and Firestore reads are native async, but the real-time
listener runs the synchronous google-cloud-firestore client in a
thread: AsyncDocumentReference.on_snapshot raises NotImplementedError
upstream, so the push subscription cannot be native async today.
async-dependency: todo
inject-websession: done
strict-typing: done
@@ -186,7 +186,6 @@ class VistapoolSelect(VistapoolEntity, SelectEntity):
translation_key="set_failed",
translation_placeholders={"entity": self.entity_id},
) from err
self.coordinator.apply_optimistic(self.entity_description.value_path, value)
class VistapoolLightModeSelect(VistapoolEntity, SelectEntity):
@@ -231,4 +230,3 @@ class VistapoolLightModeSelect(VistapoolEntity, SelectEntity):
translation_key="set_failed",
translation_placeholders={"entity": self.entity_id},
) from err
self.coordinator.apply_optimistic_values(updates)
@@ -170,4 +170,3 @@ class VistapoolSwitch(VistapoolEntity, SwitchEntity):
translation_key="set_failed",
translation_placeholders={"entity": self.entity_id},
) from err
self.coordinator.apply_optimistic(self.entity_description.value_path, value)
@@ -146,4 +146,3 @@ class VistapoolTime(VistapoolEntity, TimeEntity):
translation_key="set_failed",
translation_placeholders={"entity": self.entity_id},
) from err
self.coordinator.apply_optimistic(self.entity_description.value_path, seconds)
+1 -1
View File
@@ -203,7 +203,7 @@ aioapcaccess==1.0.0
aioaquacell==1.0.0
# homeassistant.components.vistapool
aioaquarite==0.11.0
aioaquarite==0.13.0
# homeassistant.components.aseko_pool_live
aioaseko==1.0.0
-3
View File
@@ -73,9 +73,6 @@ def mock_vistapool_client(
# default to an empty dict so always-on sensors come up with
# `native_value=None` and module-gated sensors are skipped.
client.fetch_pool_data = AsyncMock(return_value={})
# The token-refresh loop awaits `auth.get_client()` and expects
# `(client, refreshed)`.
mock_vistapool_auth.get_client = AsyncMock(return_value=(client, False))
with (
patch("homeassistant.components.vistapool.AquariteClient", return_value=client),
patch(
+35 -39
View File
@@ -10,6 +10,7 @@ import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS
from homeassistant.components.vistapool.button import _LED_PULSE_DELAY_SECONDS
from homeassistant.const import ATTR_ENTITY_ID, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
@@ -28,13 +29,6 @@ def _only_button_platform() -> Generator[None]:
yield
@pytest.fixture(autouse=True)
def _skip_pulse_delay() -> Generator[None]:
"""Skip the LED pulse delay so tests don't actually sleep."""
with patch("homeassistant.components.vistapool.button._LED_PULSE_DELAY_SECONDS", 0):
yield
async def test_all_entities(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
@@ -97,7 +91,7 @@ async def test_button_press_when_light_on(
mock_config_entry: MockConfigEntry,
mock_vistapool_client: AsyncMock,
) -> None:
"""Test pressing the button when the light is on power-cycles it."""
"""Test pressing the button when the light is on runs the library pulse."""
mock_vistapool_client.fetch_pool_data.return_value = {
"main": {"hasLED": 1, "version": 1},
"light": {"status": 1},
@@ -114,17 +108,10 @@ async def test_button_press_when_light_on(
blocking=True,
)
assert mock_vistapool_client.set_value.await_count == 2
assert mock_vistapool_client.set_value.await_args_list[0].args == (
"ABCDEF1234567890",
"light.status",
0,
)
assert mock_vistapool_client.set_value.await_args_list[1].args == (
"ABCDEF1234567890",
"light.status",
1,
mock_vistapool_client.pulse.assert_awaited_once_with(
"ABCDEF1234567890", "light.status", 0, 1, _LED_PULSE_DELAY_SECONDS
)
mock_vistapool_client.set_value.assert_not_awaited()
async def test_button_press_rapid_repeat_after_off(
@@ -132,11 +119,11 @@ async def test_button_press_rapid_repeat_after_off(
mock_config_entry: MockConfigEntry,
mock_vistapool_client: AsyncMock,
) -> None:
"""Test a second press lands the off/on pulse instead of repeating turn-on.
"""Test a second press lands the pulse instead of repeating turn-on.
Without the optimistic update, the second press would read the stale
off-state (the Firestore push hasn't round-tripped yet) and send another
bare light.status=1 — a no-op on the wire that doesn't advance the color.
The library delivers the acknowledged turn-on through the data callback
before the Firestore push round-trips, so the second press reads the
light as on and pulses it instead of sending another bare on.
"""
mock_vistapool_client.fetch_pool_data.return_value = deepcopy(_LED_DATA)
mock_config_entry.add_to_hass(hass)
@@ -144,12 +131,21 @@ async def test_button_press_rapid_repeat_after_off(
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
on_data = mock_vistapool_client.subscribe_pool_resilient.call_args.args[1]
await hass.services.async_call(
BUTTON_DOMAIN,
SERVICE_PRESS,
{ATTR_ENTITY_ID: _BUTTON},
blocking=True,
)
mock_vistapool_client.set_value.assert_awaited_once_with(
"ABCDEF1234567890", "light.status", 1
)
on_data({"main": {"hasLED": 1, "version": 1}, "light": {"status": 1}})
await hass.async_block_till_done()
await hass.services.async_call(
BUTTON_DOMAIN,
SERVICE_PRESS,
@@ -157,32 +153,32 @@ async def test_button_press_rapid_repeat_after_off(
blocking=True,
)
assert mock_vistapool_client.set_value.await_count == 3
assert mock_vistapool_client.set_value.await_args_list[0].args == (
"ABCDEF1234567890",
"light.status",
1,
)
assert mock_vistapool_client.set_value.await_args_list[1].args == (
"ABCDEF1234567890",
"light.status",
0,
)
assert mock_vistapool_client.set_value.await_args_list[2].args == (
"ABCDEF1234567890",
"light.status",
1,
mock_vistapool_client.pulse.assert_awaited_once_with(
"ABCDEF1234567890", "light.status", 0, 1, _LED_PULSE_DELAY_SECONDS
)
assert mock_vistapool_client.set_value.await_count == 1
@pytest.mark.parametrize(
("light_status", "failing_method"),
[
pytest.param(0, "set_value", id="turn_on_fails"),
pytest.param(1, "pulse", id="pulse_fails"),
],
)
async def test_button_press_raises_on_api_error(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_vistapool_client: AsyncMock,
light_status: int,
failing_method: str,
) -> None:
"""Test the button re-raises HomeAssistantError when the library fails."""
mock_vistapool_client.fetch_pool_data.return_value = deepcopy(_LED_DATA)
mock_vistapool_client.set_value.side_effect = AquariteError("boom")
mock_vistapool_client.fetch_pool_data.return_value = {
"main": {"hasLED": 1, "version": 1},
"light": {"status": light_status},
}
getattr(mock_vistapool_client, failing_method).side_effect = AquariteError("boom")
mock_config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
+16 -27
View File
@@ -298,26 +298,6 @@ async def test_setup_prunes_devices_removed_while_offline(
)
async def test_apply_optimistic_creates_missing_intermediate_dicts(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_vistapool_client: AsyncMock,
) -> None:
"""Test apply_optimistic walks through and creates missing intermediate dicts."""
mock_vistapool_client.fetch_pool_data.return_value = {"existing": "scalar"}
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()
coordinator = next(iter(mock_config_entry.runtime_data.coordinators.values()))
coordinator.apply_optimistic("filtration.intel.temp", 27)
coordinator.apply_optimistic("existing.nested.key", 1)
assert coordinator.data["filtration"]["intel"]["temp"] == 27
assert coordinator.data["existing"] == {"nested": {"key": 1}}
async def test_entities_unavailable_while_push_connection_is_down(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
@@ -326,7 +306,10 @@ async def test_entities_unavailable_while_push_connection_is_down(
"""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.
snapshot would stay on display as if it were still current. Only the
library's health transition restores availability: data it delivers in
between (an acknowledged write, a reconcile fetch) is not proof the
stream is back.
"""
mock_config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
@@ -343,10 +326,14 @@ async def test_entities_unavailable_while_push_connection_is_down(
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 == STATE_UNAVAILABLE
on_health(True)
await hass.async_block_till_done()
assert hass.states.get(_TEMPERATURE_ENTITY).state == "25.0"
@@ -357,7 +344,7 @@ async def test_entities_stay_unavailable_on_local_updates_during_outage(
) -> None:
"""Test updates that are not push snapshots do not fake availability.
Both an optimistic write and a manual refresh set the coordinator's
Both an acknowledged 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}}
@@ -366,20 +353,22 @@ async def test_entities_stay_unavailable_on_local_updates_during_outage(
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"
]
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(_LIGHT_ENTITY).state == STATE_UNAVAILABLE
# An optimistic write updates coordinator data while the push is down.
# The library delivers an acknowledged write through the data callback
# while the push is down.
await hass.services.async_call(
LIGHT_DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: _LIGHT_ENTITY},
blocking=True,
)
on_data({"light": {"status": 1}})
await hass.async_block_till_done()
assert hass.states.get(_LIGHT_ENTITY).state == STATE_UNAVAILABLE
-37
View File
@@ -98,43 +98,6 @@ async def test_light_set_value(
)
@pytest.mark.parametrize(
("service", "initial_status", "initial_state", "expected_state"),
[
pytest.param(SERVICE_TURN_ON, 0, STATE_OFF, STATE_ON, id="turn_on"),
pytest.param(SERVICE_TURN_OFF, 1, STATE_ON, STATE_OFF, id="turn_off"),
],
)
async def test_light_optimistic_state(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_vistapool_client: AsyncMock,
mock_pool_data: dict[str, Any],
service: str,
initial_status: int,
initial_state: str,
expected_state: str,
) -> None:
"""Test the entity state reflects the just-written value before the Firestore push."""
mock_pool_data["light"] = {"status": initial_status}
mock_vistapool_client.fetch_pool_data.return_value = mock_pool_data
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("light.my_pool_light").state == initial_state
await hass.services.async_call(
LIGHT_DOMAIN,
service,
{ATTR_ENTITY_ID: "light.my_pool_light"},
blocking=True,
)
assert hass.states.get("light.my_pool_light").state == expected_state
async def test_light_set_value_raises_on_api_error(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
@@ -270,7 +270,6 @@ async def test_number_set_value(
mock_vistapool_client.set_value.assert_awaited_once_with(
"ABCDEF1234567890", expected_path, expected_raw
)
assert hass.states.get(entity_id).state == str(float(user_value))
value_arg = mock_vistapool_client.set_value.await_args.args[2]
assert isinstance(value_arg, int)
+2 -89
View File
@@ -14,17 +14,12 @@ from homeassistant.components.select import (
DOMAIN as SELECT_DOMAIN,
SERVICE_SELECT_OPTION,
)
from homeassistant.const import (
ATTR_ENTITY_ID,
EVENT_STATE_CHANGED,
STATE_UNKNOWN,
Platform,
)
from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er
from tests.common import MockConfigEntry, async_capture_events, snapshot_platform
from tests.common import MockConfigEntry, snapshot_platform
@pytest.fixture(autouse=True)
@@ -420,88 +415,6 @@ async def test_select_reflects_choice_before_push(
assert hass.states.get("select.my_pool_pump_speed").state == "high"
async def test_light_mode_reflects_choice_before_push(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_vistapool_client: AsyncMock,
) -> None:
"""Test the light mode select applies every field of the chosen option."""
mock_vistapool_client.fetch_pool_data.return_value = deepcopy(_LIGHT_SCHEDULE_DATA)
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("select.my_pool_light_mode").state == "auto"
await hass.services.async_call(
SELECT_DOMAIN,
SERVICE_SELECT_OPTION,
{ATTR_ENTITY_ID: "select.my_pool_light_mode", ATTR_OPTION: "on"},
blocking=True,
)
# Reads back as on only if both light.mode and light.status were applied.
assert hass.states.get("select.my_pool_light_mode").state == "on"
async def test_light_schedule_frequency_reflects_choice_before_push(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_vistapool_client: AsyncMock,
) -> None:
"""Test the frequency select shows the chosen option immediately."""
mock_vistapool_client.fetch_pool_data.return_value = deepcopy(_LIGHT_SCHEDULE_DATA)
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()
await hass.services.async_call(
SELECT_DOMAIN,
SERVICE_SELECT_OPTION,
{
ATTR_ENTITY_ID: "select.my_pool_light_schedule_frequency",
ATTR_OPTION: "weekly",
},
blocking=True,
)
assert hass.states.get("select.my_pool_light_schedule_frequency").state == "weekly"
async def test_light_mode_never_publishes_partial_state(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_vistapool_client: AsyncMock,
) -> None:
"""Test leaving auto does not briefly read as another option.
light.mode and light.status both feed current_option, so applying them
one at a time would publish an off state between the two writes.
"""
mock_vistapool_client.fetch_pool_data.return_value = deepcopy(_LIGHT_SCHEDULE_DATA)
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()
events = async_capture_events(hass, EVENT_STATE_CHANGED)
await hass.services.async_call(
SELECT_DOMAIN,
SERVICE_SELECT_OPTION,
{ATTR_ENTITY_ID: "select.my_pool_light_mode", ATTR_OPTION: "on"},
blocking=True,
)
await hass.async_block_till_done()
states = [
event.data["new_state"].state
for event in events
if event.data["entity_id"] == "select.my_pool_light_mode"
]
assert states == ["on"]
async def test_light_mode_raises_on_api_error(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
-1
View File
@@ -150,7 +150,6 @@ async def test_time_set_value(
mock_vistapool_client.set_value.assert_awaited_once_with(
"ABCDEF1234567890", expected_path, expected_seconds
)
assert hass.states.get(entity_id).state == time_value
async def test_time_set_value_raises_on_api_error(