From 8e8d5dd48cdf79039603bfa866ea08f00f3de112 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 29 May 2026 08:41:57 +0000 Subject: [PATCH 1/5] Add light platform to Vistapool Exposes the pool light (light.status) as a single ColorMode.ONOFF light entity. Writes go through AquariteClient.set_value; AquariteError becomes a translated HomeAssistantError using the existing set_failed key. State is delivered by the existing Firestore push subscription, so no optimistic updates or explicit refresh on the write path. --- .../components/vistapool/__init__.py | 2 +- homeassistant/components/vistapool/light.py | 73 ++++++++++ .../components/vistapool/quality_scale.yaml | 8 +- .../components/vistapool/strings.json | 8 + .../vistapool/snapshots/test_light.ambr | 60 ++++++++ tests/components/vistapool/test_light.py | 137 ++++++++++++++++++ 6 files changed, 282 insertions(+), 6 deletions(-) create mode 100644 homeassistant/components/vistapool/light.py create mode 100644 tests/components/vistapool/snapshots/test_light.ambr create mode 100644 tests/components/vistapool/test_light.py diff --git a/homeassistant/components/vistapool/__init__.py b/homeassistant/components/vistapool/__init__.py index 51e0eb585330d..a47f7fa1064f3 100644 --- a/homeassistant/components/vistapool/__init__.py +++ b/homeassistant/components/vistapool/__init__.py @@ -16,7 +16,7 @@ _LOGGER = logging.getLogger(__name__) -PLATFORMS: list[Platform] = [Platform.SENSOR] +PLATFORMS: list[Platform] = [Platform.LIGHT, Platform.SENSOR] @dataclass diff --git a/homeassistant/components/vistapool/light.py b/homeassistant/components/vistapool/light.py new file mode 100644 index 0000000000000..66e44a1227f41 --- /dev/null +++ b/homeassistant/components/vistapool/light.py @@ -0,0 +1,73 @@ +"""Vistapool Light entities.""" + +from typing import Any + +from aioaquarite import AquariteError + +from homeassistant.components.light import ColorMode, LightEntity +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import VistapoolConfigEntry +from .const import DOMAIN +from .coordinator import VistapoolDataUpdateCoordinator +from .entity import VistapoolEntity + +PARALLEL_UPDATES = 1 + +_VALUE_PATH = "light.status" + + +async def async_setup_entry( + hass: HomeAssistant, + entry: VistapoolConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Vistapool light for every pool on the account.""" + async_add_entities( + VistapoolLight(coordinator) + for coordinator in entry.runtime_data.coordinators.values() + ) + + +class VistapoolLight(VistapoolEntity, LightEntity): + """Representation of a Vistapool pool light.""" + + _attr_translation_key = "pool_light" + _attr_color_mode = ColorMode.ONOFF + _attr_supported_color_modes = {ColorMode.ONOFF} + + def __init__(self, coordinator: VistapoolDataUpdateCoordinator) -> None: + """Initialize the light entity.""" + super().__init__(coordinator) + self._attr_unique_id = self.build_unique_id("pool_light") + + @property + def is_on(self) -> bool | None: + """Return true if the light is on.""" + value = self.coordinator.get_value(_VALUE_PATH) + if value is None: + return None + return value in (True, "1") + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the light on.""" + await self._async_set_value(1) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the light off.""" + await self._async_set_value(0) + + async def _async_set_value(self, value: int) -> None: + """Send a value update via the Vistapool cloud API.""" + try: + await self.coordinator.api.set_value( + self.coordinator.pool_id, _VALUE_PATH, value + ) + except AquariteError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="set_failed", + translation_placeholders={"entity": self.entity_id}, + ) from err diff --git a/homeassistant/components/vistapool/quality_scale.yaml b/homeassistant/components/vistapool/quality_scale.yaml index 72ca975859465..4ffcc19d98ae7 100644 --- a/homeassistant/components/vistapool/quality_scale.yaml +++ b/homeassistant/components/vistapool/quality_scale.yaml @@ -2,7 +2,7 @@ rules: # Bronze action-setup: status: exempt - comment: No service actions in initial sensor-only platform + comment: No integration-specific service actions; entities use platform-standard actions only appropriate-polling: done brands: done common-modules: done @@ -11,7 +11,7 @@ rules: dependency-transparency: done docs-actions: status: exempt - comment: No service actions in initial sensor-only platform + comment: No integration-specific service actions to document docs-high-level-description: done docs-installation-instructions: done docs-removal-instructions: done @@ -24,9 +24,7 @@ rules: unique-config-entry: done # Silver - action-exceptions: - status: exempt - comment: No user actions (sensor-only platform) + action-exceptions: done config-entry-unloading: done docs-configuration-parameters: status: exempt diff --git a/homeassistant/components/vistapool/strings.json b/homeassistant/components/vistapool/strings.json index 1ef44f92ec3e5..e9d809e0a369d 100644 --- a/homeassistant/components/vistapool/strings.json +++ b/homeassistant/components/vistapool/strings.json @@ -25,6 +25,11 @@ } }, "entity": { + "light": { + "pool_light": { + "name": "Pool light" + } + }, "sensor": { "chlorine": { "name": "Chlorine" @@ -59,6 +64,9 @@ "no_pools": { "message": "No pools were found on this account." }, + "set_failed": { + "message": "Failed to set {entity}." + }, "update_failed": { "message": "Error fetching data from Vistapool." } diff --git a/tests/components/vistapool/snapshots/test_light.ambr b/tests/components/vistapool/snapshots/test_light.ambr new file mode 100644 index 0000000000000..094084e0c8b74 --- /dev/null +++ b/tests/components/vistapool/snapshots/test_light.ambr @@ -0,0 +1,60 @@ +# serializer version: 1 +# name: test_all_entities[light.my_pool_pool_light-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.my_pool_pool_light', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Pool light', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Pool light', + 'platform': 'vistapool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'pool_light', + 'unique_id': 'ABCDEF1234567890-pool_light', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[light.my_pool_pool_light-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'color_mode': None, + 'friendly_name': 'My Pool Pool light', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.my_pool_pool_light', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/vistapool/test_light.py b/tests/components/vistapool/test_light.py new file mode 100644 index 0000000000000..fa6bee285f2a0 --- /dev/null +++ b/tests/components/vistapool/test_light.py @@ -0,0 +1,137 @@ +"""Tests for the Vistapool light platform.""" + +from collections.abc import Generator +from typing import Any +from unittest.mock import AsyncMock, patch + +from aioaquarite import AquariteError +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.light import ( + DOMAIN as LIGHT_DOMAIN, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.fixture(autouse=True) +def _only_light_platform() -> Generator[None]: + """Restrict integration setup to the light platform for these tests.""" + with patch("homeassistant.components.vistapool.PLATFORMS", [Platform.LIGHT]): + yield + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + mock_vistapool_client: AsyncMock, + mock_pool_data: dict[str, Any], +) -> None: + """Test light entities for the default fixture.""" + 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() + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_light_string_value( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_vistapool_client: AsyncMock, +) -> None: + """Test the light coerces a numeric-as-string status to bool.""" + mock_vistapool_client.fetch_pool_data.return_value = { + "main": {"version": 1}, + "light": {"status": "1"}, + } + 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_pool_light").state == STATE_ON + + +@pytest.mark.parametrize( + ("service", "expected_value"), + [ + pytest.param(SERVICE_TURN_ON, 1, id="turn_on"), + pytest.param(SERVICE_TURN_OFF, 0, id="turn_off"), + ], +) +async def test_light_set_value( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_vistapool_client: AsyncMock, + mock_pool_data: dict[str, Any], + service: str, + expected_value: int, +) -> None: + """Test turn_on / turn_off write light.status via set_value.""" + 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() + + await hass.services.async_call( + LIGHT_DOMAIN, + service, + {ATTR_ENTITY_ID: "light.my_pool_pool_light"}, + blocking=True, + ) + + mock_vistapool_client.set_value.assert_awaited_once_with( + "ABCDEF1234567890", "light.status", expected_value + ) + + +async def test_light_set_value_raises_on_api_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_vistapool_client: AsyncMock, + mock_pool_data: dict[str, Any], +) -> None: + """Test action raises HomeAssistantError when the library fails.""" + mock_vistapool_client.fetch_pool_data.return_value = mock_pool_data + mock_vistapool_client.set_value.side_effect = AquariteError("boom") + 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() + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "light.my_pool_pool_light"}, + blocking=True, + ) + + +async def test_light_default_fixture_state( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_vistapool_client: AsyncMock, + mock_pool_data: dict[str, Any], +) -> None: + """Test the light reports off in the default fixture (light.status=0).""" + 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_pool_light").state == STATE_OFF From bc4dbb8488d7c8987b29fa463bba3e214e44e782 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 15:48:18 +0000 Subject: [PATCH 2/5] Vistapool: assert translation_key on the light set_value error Confirms the HomeAssistantError raised by the turn_on/off handler is the expected set_failed one rather than an unrelated HomeAssistantError. --- tests/components/vistapool/test_light.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/components/vistapool/test_light.py b/tests/components/vistapool/test_light.py index fa6bee285f2a0..6d5ff6b2051cd 100644 --- a/tests/components/vistapool/test_light.py +++ b/tests/components/vistapool/test_light.py @@ -112,13 +112,14 @@ async def test_light_set_value_raises_on_api_error( assert await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() - with pytest.raises(HomeAssistantError): + with pytest.raises(HomeAssistantError) as excinfo: await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: "light.my_pool_pool_light"}, blocking=True, ) + assert excinfo.value.translation_key == "set_failed" async def test_light_default_fixture_state( From c6e7f4eefb8135b0e41ed3921f22d14d7f745ddc Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 16:03:35 +0000 Subject: [PATCH 3/5] Vistapool: rename light entity name from "Pool light" to "Light" The device name ("My Pool") is already prepended for entities with has_entity_name=True, so the friendly name was rendering as "My Pool Pool light". Drops the redundant prefix so it reads "My Pool Light". The auto-derived entity_id cascades to light.my_pool_light. --- homeassistant/components/vistapool/strings.json | 2 +- .../components/vistapool/snapshots/test_light.ambr | 14 +++++++------- tests/components/vistapool/test_light.py | 8 ++++---- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/vistapool/strings.json b/homeassistant/components/vistapool/strings.json index e9d809e0a369d..c4b96e70e4280 100644 --- a/homeassistant/components/vistapool/strings.json +++ b/homeassistant/components/vistapool/strings.json @@ -27,7 +27,7 @@ "entity": { "light": { "pool_light": { - "name": "Pool light" + "name": "Light" } }, "sensor": { diff --git a/tests/components/vistapool/snapshots/test_light.ambr b/tests/components/vistapool/snapshots/test_light.ambr index 094084e0c8b74..c3d8ac624c47c 100644 --- a/tests/components/vistapool/snapshots/test_light.ambr +++ b/tests/components/vistapool/snapshots/test_light.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_all_entities[light.my_pool_pool_light-entry] +# name: test_all_entities[light.my_pool_light-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -17,7 +17,7 @@ 'disabled_by': None, 'domain': 'light', 'entity_category': None, - 'entity_id': 'light.my_pool_pool_light', + 'entity_id': 'light.my_pool_light', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -25,12 +25,12 @@ 'labels': set({ }), 'name': None, - 'object_id_base': 'Pool light', + 'object_id_base': 'Light', 'options': dict({ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Pool light', + 'original_name': 'Light', 'platform': 'vistapool', 'previous_unique_id': None, 'suggested_object_id': None, @@ -40,18 +40,18 @@ 'unit_of_measurement': None, }) # --- -# name: test_all_entities[light.my_pool_pool_light-state] +# name: test_all_entities[light.my_pool_light-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'color_mode': None, - 'friendly_name': 'My Pool Pool light', + 'friendly_name': 'My Pool Light', 'supported_color_modes': list([ , ]), 'supported_features': , }), 'context': , - 'entity_id': 'light.my_pool_pool_light', + 'entity_id': 'light.my_pool_light', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/vistapool/test_light.py b/tests/components/vistapool/test_light.py index 6d5ff6b2051cd..5429ac14e1e3d 100644 --- a/tests/components/vistapool/test_light.py +++ b/tests/components/vistapool/test_light.py @@ -61,7 +61,7 @@ async def test_light_string_value( 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_pool_light").state == STATE_ON + assert hass.states.get("light.my_pool_light").state == STATE_ON @pytest.mark.parametrize( @@ -89,7 +89,7 @@ async def test_light_set_value( await hass.services.async_call( LIGHT_DOMAIN, service, - {ATTR_ENTITY_ID: "light.my_pool_pool_light"}, + {ATTR_ENTITY_ID: "light.my_pool_light"}, blocking=True, ) @@ -116,7 +116,7 @@ async def test_light_set_value_raises_on_api_error( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.my_pool_pool_light"}, + {ATTR_ENTITY_ID: "light.my_pool_light"}, blocking=True, ) assert excinfo.value.translation_key == "set_failed" @@ -135,4 +135,4 @@ async def test_light_default_fixture_state( 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_pool_light").state == STATE_OFF + assert hass.states.get("light.my_pool_light").state == STATE_OFF From 70821bb854731130a8644858ad4af913bfec9451 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 1 Jun 2026 12:47:01 +0000 Subject: [PATCH 4/5] Vistapool: bump aioaquarite to 0.5.1 0.5.1 wraps aiohttp.ClientError and asyncio.TimeoutError from both the REST send_command path and the upstream auth client refresh in ConnectionError (an AquariteError subclass), so transport failures surface as the translated set_failed HomeAssistantError via the existing except AquariteError clause. --- homeassistant/components/vistapool/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/vistapool/manifest.json b/homeassistant/components/vistapool/manifest.json index 1dc6f481784c8..4f2c74d962a2b 100644 --- a/homeassistant/components/vistapool/manifest.json +++ b/homeassistant/components/vistapool/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_push", "loggers": ["aioaquarite"], "quality_scale": "bronze", - "requirements": ["aioaquarite==0.4.0"] + "requirements": ["aioaquarite==0.5.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index ac80d78b42b3c..0e75d33b56752 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -203,7 +203,7 @@ aioapcaccess==1.0.0 aioaquacell==1.0.0 # homeassistant.components.vistapool -aioaquarite==0.4.0 +aioaquarite==0.5.1 # homeassistant.components.aseko_pool_live aioaseko==1.0.0 From f10080ad78223523a07372bb4bdc2344aa276fae Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 1 Jun 2026 15:12:58 +0000 Subject: [PATCH 5/5] Revert "Vistapool: bump aioaquarite to 0.5.1" This reverts commit 70821bb854731130a8644858ad4af913bfec9451. --- homeassistant/components/vistapool/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/vistapool/manifest.json b/homeassistant/components/vistapool/manifest.json index 4f2c74d962a2b..1dc6f481784c8 100644 --- a/homeassistant/components/vistapool/manifest.json +++ b/homeassistant/components/vistapool/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_push", "loggers": ["aioaquarite"], "quality_scale": "bronze", - "requirements": ["aioaquarite==0.5.1"] + "requirements": ["aioaquarite==0.4.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 0e75d33b56752..ac80d78b42b3c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -203,7 +203,7 @@ aioapcaccess==1.0.0 aioaquacell==1.0.0 # homeassistant.components.vistapool -aioaquarite==0.5.1 +aioaquarite==0.4.0 # homeassistant.components.aseko_pool_live aioaseko==1.0.0