From 10a60620ce1804a6cac8dc045762f3c4f5cb239f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 29 May 2026 07:21:23 +0000 Subject: [PATCH 1/5] Add switch platform to Vistapool Adds the switch platform that turns Vistapool from a read-only monitoring integration into one that can actually control the pool. Exposes: - filtration toggle (filtration.status) - 4 generic relay outputs (relays.relay{1-4}.info.onoff), with the read-only status field OR'd into is_on so the entity shows the effective state when the device is currently driving the relay - electrolysis cover / electrolysis boost toggles (gated on hasHidro) - heating climate toggle (dynamic, gated on filtration.hasHeat) - smart-mode freeze protection toggle (dynamic, gated on filtration.hasSmart) Writes go through aioaquarite's AquariteClient.set_value(); AquariteError is translated to HomeAssistantError with a translated message. The quality_scale.yaml action-exceptions rule moves from exempt to done now that the integration has user actions. --- .../components/vistapool/__init__.py | 2 +- .../components/vistapool/quality_scale.yaml | 8 +- .../components/vistapool/strings.json | 32 ++ homeassistant/components/vistapool/switch.py | 174 +++++++++ .../vistapool/snapshots/test_switch.ambr | 351 ++++++++++++++++++ tests/components/vistapool/test_switch.py | 166 +++++++++ 6 files changed, 727 insertions(+), 6 deletions(-) create mode 100644 homeassistant/components/vistapool/switch.py create mode 100644 tests/components/vistapool/snapshots/test_switch.ambr create mode 100644 tests/components/vistapool/test_switch.py diff --git a/homeassistant/components/vistapool/__init__.py b/homeassistant/components/vistapool/__init__.py index 51e0eb585330da..07ada4048e0243 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.SENSOR, Platform.SWITCH] @dataclass diff --git a/homeassistant/components/vistapool/quality_scale.yaml b/homeassistant/components/vistapool/quality_scale.yaml index 72ca9758594650..99343e755c4eac 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 service actions 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 service actions 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 1ef44f92ec3e5c..12b2827a929834 100644 --- a/homeassistant/components/vistapool/strings.json +++ b/homeassistant/components/vistapool/strings.json @@ -50,6 +50,35 @@ "uv": { "name": "UV" } + }, + "switch": { + "electrolysis_boost": { + "name": "Electrolysis boost" + }, + "electrolysis_cover": { + "name": "Electrolysis cover" + }, + "filtration": { + "name": "Filtration" + }, + "heating_climate": { + "name": "Heating climate" + }, + "relay_1": { + "name": "Relay 1" + }, + "relay_2": { + "name": "Relay 2" + }, + "relay_3": { + "name": "Relay 3" + }, + "relay_4": { + "name": "Relay 4" + }, + "smart_mode_freeze": { + "name": "Smart mode freeze" + } } }, "exceptions": { @@ -59,6 +88,9 @@ "no_pools": { "message": "No pools were found on this account." }, + "set_failed": { + "message": "Failed to update {entity}." + }, "update_failed": { "message": "Error fetching data from Vistapool." } diff --git a/homeassistant/components/vistapool/switch.py b/homeassistant/components/vistapool/switch.py new file mode 100644 index 00000000000000..feebaba95a5a26 --- /dev/null +++ b/homeassistant/components/vistapool/switch.py @@ -0,0 +1,174 @@ +"""Vistapool Switch entities.""" + +from dataclasses import dataclass +from typing import Any + +from aioaquarite import AquariteError + +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import VistapoolConfigEntry +from .const import DOMAIN, PATH_HASHIDRO +from .coordinator import VistapoolDataUpdateCoordinator +from .entity import VistapoolEntity + +PARALLEL_UPDATES = 1 + + +@dataclass(frozen=True, kw_only=True) +class VistapoolSwitchEntityDescription(SwitchEntityDescription): + """Describes a Vistapool switch entity.""" + + value_path: str + is_relay: bool = False + exists_path: str | tuple[str, ...] | None = None + + +SWITCH_DESCRIPTIONS: tuple[VistapoolSwitchEntityDescription, ...] = ( + VistapoolSwitchEntityDescription( + key="filtration", + translation_key="filtration", + value_path="filtration.status", + ), + VistapoolSwitchEntityDescription( + key="relay_1", + translation_key="relay_1", + value_path="relays.relay1.info.onoff", + is_relay=True, + ), + VistapoolSwitchEntityDescription( + key="relay_2", + translation_key="relay_2", + value_path="relays.relay2.info.onoff", + is_relay=True, + ), + VistapoolSwitchEntityDescription( + key="relay_3", + translation_key="relay_3", + value_path="relays.relay3.info.onoff", + is_relay=True, + ), + VistapoolSwitchEntityDescription( + key="relay_4", + translation_key="relay_4", + value_path="relays.relay4.info.onoff", + is_relay=True, + ), + VistapoolSwitchEntityDescription( + key="electrolysis_cover", + translation_key="electrolysis_cover", + value_path="hidro.cover_enabled", + exists_path=PATH_HASHIDRO, + ), + VistapoolSwitchEntityDescription( + key="electrolysis_boost", + translation_key="electrolysis_boost", + value_path="hidro.cloration_enabled", + exists_path=PATH_HASHIDRO, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: VistapoolConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Vistapool switches for every pool on the account.""" + entities: list[SwitchEntity] = [] + + for coordinator in entry.runtime_data.coordinators.values(): + for description in SWITCH_DESCRIPTIONS: + if description.exists_path is not None: + required = ( + (description.exists_path,) + if isinstance(description.exists_path, str) + else description.exists_path + ) + if not all(coordinator.get_value(path) for path in required): + continue + entities.append(VistapoolSwitch(coordinator, description)) + + if coordinator.get_value("filtration.hasHeat"): + entities.append( + VistapoolSwitch( + coordinator, + VistapoolSwitchEntityDescription( + key="heating_climate", + translation_key="heating_climate", + value_path="filtration.heating.clima", + ), + ) + ) + + if coordinator.get_value("filtration.hasSmart"): + entities.append( + VistapoolSwitch( + coordinator, + VistapoolSwitchEntityDescription( + key="smart_mode_freeze", + translation_key="smart_mode_freeze", + value_path="filtration.smart.freeze", + ), + ) + ) + + async_add_entities(entities) + + +class VistapoolSwitch(VistapoolEntity, SwitchEntity): + """Generic Vistapool switch driven by an entity description.""" + + entity_description: VistapoolSwitchEntityDescription + + def __init__( + self, + coordinator: VistapoolDataUpdateCoordinator, + description: VistapoolSwitchEntityDescription, + ) -> None: + """Initialize the switch.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = self.build_unique_id(description.key) + + @property + def is_on(self) -> bool | None: + """Return true if the switch is on.""" + value = self.coordinator.get_value(self.entity_description.value_path) + if value is None: + return None + on = value in (True, "1") + if self.entity_description.is_relay: + # Relays report a separate read-only `status` next to the writable + # `onoff`; show the switch as on when either is truthy. + status_path = self.entity_description.value_path.replace("onoff", "status") + status = self.coordinator.get_value(status_path) + if status is not None: + on = on or status in (True, "1") + return on + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the switch on.""" + await self._async_set_value(1) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the switch 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, + self.entity_description.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/tests/components/vistapool/snapshots/test_switch.ambr b/tests/components/vistapool/snapshots/test_switch.ambr new file mode 100644 index 00000000000000..f1712145b93e13 --- /dev/null +++ b/tests/components/vistapool/snapshots/test_switch.ambr @@ -0,0 +1,351 @@ +# serializer version: 1 +# name: test_all_entities[switch.my_pool_electrolysis_boost-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.my_pool_electrolysis_boost', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Electrolysis boost', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Electrolysis boost', + 'platform': 'vistapool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electrolysis_boost', + 'unique_id': 'ABCDEF1234567890-electrolysis_boost', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[switch.my_pool_electrolysis_boost-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'My Pool Electrolysis boost', + }), + 'context': , + 'entity_id': 'switch.my_pool_electrolysis_boost', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[switch.my_pool_electrolysis_cover-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.my_pool_electrolysis_cover', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Electrolysis cover', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Electrolysis cover', + 'platform': 'vistapool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electrolysis_cover', + 'unique_id': 'ABCDEF1234567890-electrolysis_cover', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[switch.my_pool_electrolysis_cover-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'My Pool Electrolysis cover', + }), + 'context': , + 'entity_id': 'switch.my_pool_electrolysis_cover', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[switch.my_pool_filtration-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.my_pool_filtration', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Filtration', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Filtration', + 'platform': 'vistapool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'filtration', + 'unique_id': 'ABCDEF1234567890-filtration', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[switch.my_pool_filtration-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'My Pool Filtration', + }), + 'context': , + 'entity_id': 'switch.my_pool_filtration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[switch.my_pool_relay_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.my_pool_relay_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Relay 1', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Relay 1', + 'platform': 'vistapool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'relay_1', + 'unique_id': 'ABCDEF1234567890-relay_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[switch.my_pool_relay_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'My Pool Relay 1', + }), + 'context': , + 'entity_id': 'switch.my_pool_relay_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[switch.my_pool_relay_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.my_pool_relay_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Relay 2', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Relay 2', + 'platform': 'vistapool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'relay_2', + 'unique_id': 'ABCDEF1234567890-relay_2', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[switch.my_pool_relay_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'My Pool Relay 2', + }), + 'context': , + 'entity_id': 'switch.my_pool_relay_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[switch.my_pool_relay_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.my_pool_relay_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Relay 3', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Relay 3', + 'platform': 'vistapool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'relay_3', + 'unique_id': 'ABCDEF1234567890-relay_3', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[switch.my_pool_relay_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'My Pool Relay 3', + }), + 'context': , + 'entity_id': 'switch.my_pool_relay_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[switch.my_pool_relay_4-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.my_pool_relay_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Relay 4', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Relay 4', + 'platform': 'vistapool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'relay_4', + 'unique_id': 'ABCDEF1234567890-relay_4', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[switch.my_pool_relay_4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'My Pool Relay 4', + }), + 'context': , + 'entity_id': 'switch.my_pool_relay_4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/vistapool/test_switch.py b/tests/components/vistapool/test_switch.py new file mode 100644 index 00000000000000..8efaa5d009546b --- /dev/null +++ b/tests/components/vistapool/test_switch.py @@ -0,0 +1,166 @@ +"""Tests for the Vistapool switch 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.switch import ( + DOMAIN as SWITCH_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_switch_platform() -> Generator[None]: + """Restrict integration setup to the switch platform for these tests.""" + with patch("homeassistant.components.vistapool.PLATFORMS", [Platform.SWITCH]): + 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 switch 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_switch_relay_status_fallback( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_vistapool_client: AsyncMock, +) -> None: + """Test relay switches report on when the read-only status is set.""" + mock_vistapool_client.fetch_pool_data.return_value = { + "main": {"version": 1}, + "relays": { + "relay1": {"info": {"onoff": 0, "status": 1}}, + "relay2": {"info": {"onoff": 1, "status": 0}}, + "relay3": {"info": {"onoff": 0, "status": 0}}, + "relay4": {"info": {"onoff": 0, "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 hass.states.get("switch.my_pool_relay_1").state == STATE_ON + assert hass.states.get("switch.my_pool_relay_2").state == STATE_ON + assert hass.states.get("switch.my_pool_relay_3").state == STATE_OFF + + +async def test_switch_heating_climate_requires_has_heat( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_vistapool_client: AsyncMock, +) -> None: + """Test heating_climate is only created when filtration.hasHeat is set.""" + mock_vistapool_client.fetch_pool_data.return_value = { + "main": {"version": 1}, + "filtration": {"hasHeat": 1, "heating": {"clima": 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 hass.states.get("switch.my_pool_heating_climate") is not None + assert hass.states.get("switch.my_pool_smart_mode_freeze") is None + + +async def test_switch_smart_mode_freeze_requires_has_smart( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_vistapool_client: AsyncMock, +) -> None: + """Test smart_mode_freeze is only created when filtration.hasSmart is set.""" + mock_vistapool_client.fetch_pool_data.return_value = { + "main": {"version": 1}, + "filtration": {"hasSmart": 1, "smart": {"freeze": 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("switch.my_pool_smart_mode_freeze").state == STATE_ON + assert hass.states.get("switch.my_pool_heating_climate") is None + + +@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_switch_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 call the library's set_value with the right args.""" + 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( + SWITCH_DOMAIN, + service, + {ATTR_ENTITY_ID: "switch.my_pool_filtration"}, + blocking=True, + ) + + mock_vistapool_client.set_value.assert_awaited_once_with( + "ABCDEF1234567890", "filtration.status", expected_value + ) + + +async def test_switch_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( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "switch.my_pool_filtration"}, + blocking=True, + ) From 14ebea13ab2526cf5237342ad3079e4f314eb0d8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 15:48:00 +0000 Subject: [PATCH 2/5] Vistapool: assert translation_key on the switch 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_switch.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/components/vistapool/test_switch.py b/tests/components/vistapool/test_switch.py index 8efaa5d009546b..7311dfd58ac199 100644 --- a/tests/components/vistapool/test_switch.py +++ b/tests/components/vistapool/test_switch.py @@ -157,10 +157,11 @@ async def test_switch_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( SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: "switch.my_pool_filtration"}, blocking=True, ) + assert excinfo.value.translation_key == "set_failed" From 5319541b305fdd1b6624801b501ec38b5bc2bbc7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 1 Jun 2026 12:46:58 +0000 Subject: [PATCH 3/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 1dc6f481784c8a..4f2c74d962a2be 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 ac80d78b42b3c5..0e75d33b567523 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 7d5be1d7cecf973cf64f5fc553468a4183d05b74 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 1 Jun 2026 15:12:55 +0000 Subject: [PATCH 4/5] Revert "Vistapool: bump aioaquarite to 0.5.1" This reverts commit 5319541b305fdd1b6624801b501ec38b5bc2bbc7. --- 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 4f2c74d962a2be..1dc6f481784c8a 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 0e75d33b567523..ac80d78b42b3c5 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 From ba673f4cf16116da57318672205ee08ed8745b29 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 2 Jun 2026 15:26:12 +0000 Subject: [PATCH 5/5] Vistapool: collapse the four relay translation keys into one with placeholder Per joostlek's review: instead of four near-identical translation keys (relay_1..relay_4) each with their own strings.json entry, use a single 'relay' key with a {number} placeholder and pass the index via translation_placeholders on each description. - Adds translation_placeholders to VistapoolSwitchEntityDescription (since SwitchEntityDescription doesn't carry it). - Replaces the four hand-written relay descriptions with a generator expression. - Wires the placeholders through to the entity via _attr_translation_placeholders. - Collapses four strings.json entries into one. unique_id is preserved (still uses the 'key' field: relay_1..relay_4), so existing entity IDs and registry entries stay valid. The snapshot reflects the new shared translation_key; original_name still resolves to 'Relay 1', 'Relay 2', etc. through the placeholders. --- .../components/vistapool/strings.json | 13 ++----- homeassistant/components/vistapool/switch.py | 35 +++++++------------ .../vistapool/snapshots/test_switch.ambr | 8 ++--- 3 files changed, 18 insertions(+), 38 deletions(-) diff --git a/homeassistant/components/vistapool/strings.json b/homeassistant/components/vistapool/strings.json index 9cb87daf7bb621..0cde8634797250 100644 --- a/homeassistant/components/vistapool/strings.json +++ b/homeassistant/components/vistapool/strings.json @@ -71,17 +71,8 @@ "heating_climate": { "name": "Heating climate" }, - "relay_1": { - "name": "Relay 1" - }, - "relay_2": { - "name": "Relay 2" - }, - "relay_3": { - "name": "Relay 3" - }, - "relay_4": { - "name": "Relay 4" + "relay": { + "name": "Relay {number}" }, "smart_mode_freeze": { "name": "Smart mode freeze" diff --git a/homeassistant/components/vistapool/switch.py b/homeassistant/components/vistapool/switch.py index feebaba95a5a26..90bb9fa0299a75 100644 --- a/homeassistant/components/vistapool/switch.py +++ b/homeassistant/components/vistapool/switch.py @@ -25,6 +25,7 @@ class VistapoolSwitchEntityDescription(SwitchEntityDescription): value_path: str is_relay: bool = False exists_path: str | tuple[str, ...] | None = None + translation_placeholders: dict[str, str] | None = None SWITCH_DESCRIPTIONS: tuple[VistapoolSwitchEntityDescription, ...] = ( @@ -33,29 +34,15 @@ class VistapoolSwitchEntityDescription(SwitchEntityDescription): translation_key="filtration", value_path="filtration.status", ), - VistapoolSwitchEntityDescription( - key="relay_1", - translation_key="relay_1", - value_path="relays.relay1.info.onoff", - is_relay=True, - ), - VistapoolSwitchEntityDescription( - key="relay_2", - translation_key="relay_2", - value_path="relays.relay2.info.onoff", - is_relay=True, - ), - VistapoolSwitchEntityDescription( - key="relay_3", - translation_key="relay_3", - value_path="relays.relay3.info.onoff", - is_relay=True, - ), - VistapoolSwitchEntityDescription( - key="relay_4", - translation_key="relay_4", - value_path="relays.relay4.info.onoff", - is_relay=True, + *( + VistapoolSwitchEntityDescription( + key=f"relay_{i}", + translation_key="relay", + translation_placeholders={"number": str(i)}, + value_path=f"relays.relay{i}.info.onoff", + is_relay=True, + ) + for i in (1, 2, 3, 4) ), VistapoolSwitchEntityDescription( key="electrolysis_cover", @@ -133,6 +120,8 @@ def __init__( super().__init__(coordinator) self.entity_description = description self._attr_unique_id = self.build_unique_id(description.key) + if description.translation_placeholders is not None: + self._attr_translation_placeholders = description.translation_placeholders @property def is_on(self) -> bool | None: diff --git a/tests/components/vistapool/snapshots/test_switch.ambr b/tests/components/vistapool/snapshots/test_switch.ambr index f1712145b93e13..e3ee3ae9c7e4aa 100644 --- a/tests/components/vistapool/snapshots/test_switch.ambr +++ b/tests/components/vistapool/snapshots/test_switch.ambr @@ -181,7 +181,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'relay_1', + 'translation_key': 'relay', 'unique_id': 'ABCDEF1234567890-relay_1', 'unit_of_measurement': None, }) @@ -231,7 +231,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'relay_2', + 'translation_key': 'relay', 'unique_id': 'ABCDEF1234567890-relay_2', 'unit_of_measurement': None, }) @@ -281,7 +281,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'relay_3', + 'translation_key': 'relay', 'unique_id': 'ABCDEF1234567890-relay_3', 'unit_of_measurement': None, }) @@ -331,7 +331,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'relay_4', + 'translation_key': 'relay', 'unique_id': 'ABCDEF1234567890-relay_4', 'unit_of_measurement': None, })