diff --git a/homeassistant/components/vistapool/__init__.py b/homeassistant/components/vistapool/__init__.py index d7a01cbd6578..ebd730b93c22 100644 --- a/homeassistant/components/vistapool/__init__.py +++ b/homeassistant/components/vistapool/__init__.py @@ -36,6 +36,7 @@ PLATFORMS: list[Platform] = [ Platform.NUMBER, Platform.SELECT, Platform.SENSOR, + Platform.TIME, ] diff --git a/homeassistant/components/vistapool/icons.json b/homeassistant/components/vistapool/icons.json index 1ae79f516475..4746854e1541 100644 --- a/homeassistant/components/vistapool/icons.json +++ b/homeassistant/components/vistapool/icons.json @@ -22,6 +22,14 @@ "uv": { "default": "mdi:weather-sunny-alert" } + }, + "time": { + "filtration_interval_end": { + "default": "mdi:clock-end" + }, + "filtration_interval_start": { + "default": "mdi:clock-start" + } } } } diff --git a/homeassistant/components/vistapool/strings.json b/homeassistant/components/vistapool/strings.json index f61ec754f5eb..62fbb793c2c3 100644 --- a/homeassistant/components/vistapool/strings.json +++ b/homeassistant/components/vistapool/strings.json @@ -207,6 +207,14 @@ "uv": { "name": "UV" } + }, + "time": { + "filtration_interval_end": { + "name": "Filtration interval {number} end" + }, + "filtration_interval_start": { + "name": "Filtration interval {number} start" + } } }, "exceptions": { diff --git a/homeassistant/components/vistapool/time.py b/homeassistant/components/vistapool/time.py new file mode 100644 index 000000000000..30b7b005d9a6 --- /dev/null +++ b/homeassistant/components/vistapool/time.py @@ -0,0 +1,134 @@ +"""Vistapool Time entities.""" + +from dataclasses import dataclass +from datetime import time +from typing import override + +from aioaquarite import AquariteError + +from homeassistant.components.time import TimeEntity, TimeEntityDescription +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import VistapoolConfigEntry +from .const import DOMAIN, SIGNAL_NEW_POOL +from .coordinator import VistapoolDataUpdateCoordinator +from .entity import VistapoolEntity + +PARALLEL_UPDATES = 1 + +_SECONDS_PER_HOUR = 3600 +_SECONDS_PER_MINUTE = 60 + + +@dataclass(frozen=True, kw_only=True) +class VistapoolTimeEntityDescription(TimeEntityDescription): + """Describes a Vistapool time entity.""" + + value_path: str + translation_placeholders: dict[str, str] + + +TIME_DESCRIPTIONS: tuple[VistapoolTimeEntityDescription, ...] = tuple( + VistapoolTimeEntityDescription( + key=f"filtration_interval_{interval}_{bound}", + translation_key=f"filtration_interval_{bound}", + translation_placeholders={"number": str(interval)}, + entity_category=EntityCategory.CONFIG, + value_path=f"filtration.interval{interval}.{api_field}", + ) + for interval in (1, 2, 3) + for bound, api_field in (("start", "from"), ("end", "to")) +) + + +def _build_time_entities( + coordinator: VistapoolDataUpdateCoordinator, +) -> list[TimeEntity]: + """Build the time entities for a single pool.""" + return [ + VistapoolTime(coordinator, description) for description in TIME_DESCRIPTIONS + ] + + +async def async_setup_entry( + hass: HomeAssistant, + entry: VistapoolConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Vistapool time entities for every pool on the account.""" + entities: list[TimeEntity] = [] + for coordinator in entry.runtime_data.coordinators.values(): + entities.extend(_build_time_entities(coordinator)) + async_add_entities(entities) + + @callback + def _async_add_pool(coordinator: VistapoolDataUpdateCoordinator) -> None: + async_add_entities(_build_time_entities(coordinator)) + + entry.async_on_unload( + async_dispatcher_connect( + hass, f"{SIGNAL_NEW_POOL}_{entry.entry_id}", _async_add_pool + ) + ) + + +class VistapoolTime(VistapoolEntity, TimeEntity): + """Generic Vistapool time driven by an entity description.""" + + entity_description: VistapoolTimeEntityDescription + + def __init__( + self, + coordinator: VistapoolDataUpdateCoordinator, + description: VistapoolTimeEntityDescription, + ) -> None: + """Initialize the time entity.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = self.build_unique_id(description.key) + self._attr_translation_placeholders = description.translation_placeholders + + @property + @override + def native_value(self) -> time | None: + """Return the interval bound as a time, decoded from seconds since midnight.""" + raw = self.coordinator.get_value(self.entity_description.value_path) + if raw is None: + return None + try: + seconds = int(raw) + return time( + seconds // _SECONDS_PER_HOUR, + (seconds % _SECONDS_PER_HOUR) // _SECONDS_PER_MINUTE, + seconds % _SECONDS_PER_MINUTE, + ) + except TypeError, ValueError: + # Also covers out-of-range values (negative or >= 24h), which make + # the time() constructor raise instead of silently wrapping. + return None + + @override + async def async_set_value(self, value: time) -> None: + """Send the interval bound to the controller as seconds since midnight.""" + seconds = ( + value.hour * _SECONDS_PER_HOUR + + value.minute * _SECONDS_PER_MINUTE + + value.second + ) + try: + await self.coordinator.api.set_value( + self.coordinator.pool_id, + self.entity_description.value_path, + seconds, + ) + 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(self.entity_description.value_path, seconds) diff --git a/tests/components/vistapool/snapshots/test_time.ambr b/tests/components/vistapool/snapshots/test_time.ambr new file mode 100644 index 000000000000..923601a70dc6 --- /dev/null +++ b/tests/components/vistapool/snapshots/test_time.ambr @@ -0,0 +1,301 @@ +# serializer version: 1 +# name: test_all_entities[time.my_pool_filtration_interval_1_end-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': 'time', + 'entity_category': , + 'entity_id': 'time.my_pool_filtration_interval_1_end', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Filtration interval 1 end', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Filtration interval 1 end', + 'platform': 'vistapool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'filtration_interval_end', + 'unique_id': 'ABCDEF1234567890-filtration_interval_1_end', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[time.my_pool_filtration_interval_1_end-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'My Pool Filtration interval 1 end', + }), + 'context': , + 'entity_id': 'time.my_pool_filtration_interval_1_end', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10:00:00', + }) +# --- +# name: test_all_entities[time.my_pool_filtration_interval_1_start-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': 'time', + 'entity_category': , + 'entity_id': 'time.my_pool_filtration_interval_1_start', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Filtration interval 1 start', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Filtration interval 1 start', + 'platform': 'vistapool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'filtration_interval_start', + 'unique_id': 'ABCDEF1234567890-filtration_interval_1_start', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[time.my_pool_filtration_interval_1_start-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'My Pool Filtration interval 1 start', + }), + 'context': , + 'entity_id': 'time.my_pool_filtration_interval_1_start', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '08:00:00', + }) +# --- +# name: test_all_entities[time.my_pool_filtration_interval_2_end-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': 'time', + 'entity_category': , + 'entity_id': 'time.my_pool_filtration_interval_2_end', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Filtration interval 2 end', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Filtration interval 2 end', + 'platform': 'vistapool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'filtration_interval_end', + 'unique_id': 'ABCDEF1234567890-filtration_interval_2_end', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[time.my_pool_filtration_interval_2_end-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'My Pool Filtration interval 2 end', + }), + 'context': , + 'entity_id': 'time.my_pool_filtration_interval_2_end', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '14:00:00', + }) +# --- +# name: test_all_entities[time.my_pool_filtration_interval_2_start-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': 'time', + 'entity_category': , + 'entity_id': 'time.my_pool_filtration_interval_2_start', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Filtration interval 2 start', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Filtration interval 2 start', + 'platform': 'vistapool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'filtration_interval_start', + 'unique_id': 'ABCDEF1234567890-filtration_interval_2_start', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[time.my_pool_filtration_interval_2_start-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'My Pool Filtration interval 2 start', + }), + 'context': , + 'entity_id': 'time.my_pool_filtration_interval_2_start', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '13:00:00', + }) +# --- +# name: test_all_entities[time.my_pool_filtration_interval_3_end-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': 'time', + 'entity_category': , + 'entity_id': 'time.my_pool_filtration_interval_3_end', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Filtration interval 3 end', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Filtration interval 3 end', + 'platform': 'vistapool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'filtration_interval_end', + 'unique_id': 'ABCDEF1234567890-filtration_interval_3_end', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[time.my_pool_filtration_interval_3_end-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'My Pool Filtration interval 3 end', + }), + 'context': , + 'entity_id': 'time.my_pool_filtration_interval_3_end', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '19:30:00', + }) +# --- +# name: test_all_entities[time.my_pool_filtration_interval_3_start-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': 'time', + 'entity_category': , + 'entity_id': 'time.my_pool_filtration_interval_3_start', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Filtration interval 3 start', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Filtration interval 3 start', + 'platform': 'vistapool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'filtration_interval_start', + 'unique_id': 'ABCDEF1234567890-filtration_interval_3_start', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[time.my_pool_filtration_interval_3_start-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'My Pool Filtration interval 3 start', + }), + 'context': , + 'entity_id': 'time.my_pool_filtration_interval_3_start', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '19:00:00', + }) +# --- diff --git a/tests/components/vistapool/test_time.py b/tests/components/vistapool/test_time.py new file mode 100644 index 000000000000..09ecbf0ff4b9 --- /dev/null +++ b/tests/components/vistapool/test_time.py @@ -0,0 +1,179 @@ +"""Tests for the Vistapool time 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.time import ( + ATTR_TIME, + DOMAIN as TIME_DOMAIN, + SERVICE_SET_VALUE, +) +from homeassistant.const import ATTR_ENTITY_ID, 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_time_platform() -> Generator[None]: + """Restrict integration setup to the time platform for these tests.""" + with patch("homeassistant.components.vistapool.PLATFORMS", [Platform.TIME]): + 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 time 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_time_decodes_seconds_since_midnight( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_vistapool_client: AsyncMock, + mock_pool_data: dict[str, Any], +) -> None: + """Test the stored seconds-since-midnight are decoded to a time.""" + 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() + + # Fixture stores interval1 from=28800 (08:00) and to=36000 (10:00). + assert hass.states.get("time.my_pool_filtration_interval_1_start").state == ( + "08:00:00" + ) + assert hass.states.get("time.my_pool_filtration_interval_1_end").state == "10:00:00" + + +@pytest.mark.parametrize( + "raw_value", + [ + pytest.param("garbage", id="non_numeric"), + pytest.param(None, id="missing"), + pytest.param(86400, id="out_of_range_high"), + pytest.param(-60, id="negative"), + ], +) +async def test_time_native_value_unknown_when_unparsable( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_vistapool_client: AsyncMock, + raw_value: Any, +) -> None: + """Test an unparsable or out-of-range raw value yields an unknown state.""" + mock_vistapool_client.fetch_pool_data.return_value = { + "main": {"version": 1}, + "filtration": {"interval1": {"from": raw_value}}, + } + 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("time.my_pool_filtration_interval_1_start").state == "unknown" + ) + + +@pytest.mark.parametrize( + ("entity_id", "time_value", "expected_path", "expected_seconds"), + [ + pytest.param( + "time.my_pool_filtration_interval_1_start", + "12:30:00", + "filtration.interval1.from", + 45000, + id="interval_1_start", + ), + pytest.param( + "time.my_pool_filtration_interval_2_end", + "12:30:00", + "filtration.interval2.to", + 45000, + id="interval_2_end", + ), + pytest.param( + "time.my_pool_filtration_interval_3_start", + "07:05:09", + "filtration.interval3.from", + 25509, + id="interval_3_start_with_seconds", + ), + ], +) +async def test_time_set_value( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_vistapool_client: AsyncMock, + mock_pool_data: dict[str, Any], + entity_id: str, + time_value: str, + expected_path: str, + expected_seconds: int, +) -> None: + """Test set_value encodes the time as seconds since midnight at the right path.""" + 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( + TIME_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: entity_id, ATTR_TIME: time_value}, + blocking=True, + ) + + 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( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_vistapool_client: AsyncMock, + mock_pool_data: dict[str, Any], +) -> None: + """Test set_value re-raises as 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( + TIME_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: "time.my_pool_filtration_interval_1_start", + ATTR_TIME: "12:30:00", + }, + blocking=True, + ) + assert excinfo.value.translation_key == "set_failed"