Add time platform to Vistapool (#177749)

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
fdebrus
2026-08-24 22:01:01 +02:00
committed by GitHub
co-authored by Claude
parent 834505ff62
commit 14c279bde0
6 changed files with 631 additions and 0 deletions
@@ -36,6 +36,7 @@ PLATFORMS: list[Platform] = [
Platform.NUMBER,
Platform.SELECT,
Platform.SENSOR,
Platform.TIME,
]
@@ -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"
}
}
}
}
@@ -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": {
+134
View File
@@ -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)
@@ -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': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'time',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'time.my_pool_filtration_interval_1_end',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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({
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'My Pool Filtration interval 1 end',
}),
'context': <ANY>,
'entity_id': 'time.my_pool_filtration_interval_1_end',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'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': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'time',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'time.my_pool_filtration_interval_1_start',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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({
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'My Pool Filtration interval 1 start',
}),
'context': <ANY>,
'entity_id': 'time.my_pool_filtration_interval_1_start',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'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': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'time',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'time.my_pool_filtration_interval_2_end',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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({
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'My Pool Filtration interval 2 end',
}),
'context': <ANY>,
'entity_id': 'time.my_pool_filtration_interval_2_end',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'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': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'time',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'time.my_pool_filtration_interval_2_start',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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({
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'My Pool Filtration interval 2 start',
}),
'context': <ANY>,
'entity_id': 'time.my_pool_filtration_interval_2_start',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'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': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'time',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'time.my_pool_filtration_interval_3_end',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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({
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'My Pool Filtration interval 3 end',
}),
'context': <ANY>,
'entity_id': 'time.my_pool_filtration_interval_3_end',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'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': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'time',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'time.my_pool_filtration_interval_3_start',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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({
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'My Pool Filtration interval 3 start',
}),
'context': <ANY>,
'entity_id': 'time.my_pool_filtration_interval_3_start',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '19:00:00',
})
# ---
+179
View File
@@ -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"