diff --git a/homeassistant/components/vistapool/__init__.py b/homeassistant/components/vistapool/__init__.py index fd7321a16625a6..d5b08f07066893 100644 --- a/homeassistant/components/vistapool/__init__.py +++ b/homeassistant/components/vistapool/__init__.py @@ -16,7 +16,12 @@ _LOGGER = logging.getLogger(__name__) -PLATFORMS: list[Platform] = [Platform.BUTTON, Platform.NUMBER, Platform.SENSOR] +PLATFORMS: list[Platform] = [ + Platform.BUTTON, + Platform.NUMBER, + Platform.SENSOR, + Platform.SWITCH, +] @dataclass diff --git a/homeassistant/components/vistapool/strings.json b/homeassistant/components/vistapool/strings.json index e12ecfe545ec39..79377ff911fe74 100644 --- a/homeassistant/components/vistapool/strings.json +++ b/homeassistant/components/vistapool/strings.json @@ -89,6 +89,26 @@ "uv": { "name": "UV" } + }, + "switch": { + "electrolysis_boost": { + "name": "Electrolysis boost" + }, + "electrolysis_cover": { + "name": "Electrolysis cover" + }, + "filtration": { + "name": "Filtration" + }, + "heating_climate": { + "name": "Heating climate" + }, + "relay": { + "name": "Relay {number}" + }, + "smart_mode_freeze": { + "name": "Smart mode freeze" + } } }, "exceptions": { diff --git a/homeassistant/components/vistapool/switch.py b/homeassistant/components/vistapool/switch.py new file mode 100644 index 00000000000000..90bb9fa0299a75 --- /dev/null +++ b/homeassistant/components/vistapool/switch.py @@ -0,0 +1,163 @@ +"""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 + translation_placeholders: dict[str, str] | None = None + + +SWITCH_DESCRIPTIONS: tuple[VistapoolSwitchEntityDescription, ...] = ( + VistapoolSwitchEntityDescription( + key="filtration", + translation_key="filtration", + value_path="filtration.status", + ), + *( + 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", + 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) + if description.translation_placeholders is not None: + self._attr_translation_placeholders = description.translation_placeholders + + @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..e3ee3ae9c7e4aa --- /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', + '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', + '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', + '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', + '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..7311dfd58ac199 --- /dev/null +++ b/tests/components/vistapool/test_switch.py @@ -0,0 +1,167 @@ +"""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) 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"