Add pool light scheduling to Vistapool (#180686)

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
fdebrus
2026-08-30 16:48:42 +02:00
committed by GitHub
co-authored by Claude
parent 4d22d19381
commit f23cda199d
7 changed files with 573 additions and 32 deletions
@@ -125,13 +125,23 @@ class VistapoolDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
coordinator.data after a successful REST call gives entities instant
feedback; the next snapshot from Firestore overwrites it harmlessly.
"""
keys = value_path.split(".")
target: dict[str, Any] = self.data
for key in keys[:-1]:
child = target.get(key)
if not isinstance(child, dict):
child = {}
target[key] = child
target = child
target[keys[-1]] = value
self.apply_optimistic_values({value_path: value})
def apply_optimistic_values(self, updates: dict[str, Any]) -> None:
"""Reflect several just-written values as a single update.
Applying them one at a time would publish a state where only part
of the write has landed, which entities derived from more than one
path briefly read as a different value.
"""
for value_path, value in updates.items():
keys = value_path.split(".")
target: dict[str, Any] = self.data
for key in keys[:-1]:
child = target.get(key)
if not isinstance(child, dict):
child = {}
target[key] = child
target = child
target[keys[-1]] = value
self.async_set_updated_data(self.data)
@@ -1,5 +1,13 @@
{
"entity": {
"select": {
"light_mode": {
"default": "mdi:lightbulb-auto"
},
"light_schedule_frequency": {
"default": "mdi:calendar-refresh"
}
},
"sensor": {
"chlorine": {
"default": "mdi:gauge"
@@ -29,6 +37,12 @@
},
"filtration_interval_start": {
"default": "mdi:clock-start"
},
"light_schedule_end": {
"default": "mdi:clock-end"
},
"light_schedule_start": {
"default": "mdi:clock-start"
}
}
}
+97 -10
View File
@@ -22,13 +22,31 @@ PARALLEL_UPDATES = 1
_PUMP_MODE_OPTIONS = ["manual", "auto", "heat", "smart", "intel"]
_PUMP_SPEED_OPTIONS = ["slow", "medium", "high"]
_LIGHT_FREQUENCIES = {"daily": 86400, "weekly": 604800}
_LIGHT_MODE_PATH = "light.mode"
_LIGHT_STATUS_PATH = "light.status"
# Off and on leave schedule mode; auto only re-arms it and lets the
# controller's own schedule drive light.status. Each option must land as one
# command, or the controller sees a half-applied state.
_LIGHT_MODE_UPDATES: dict[str, dict[str, int]] = {
"off": {_LIGHT_MODE_PATH: 0, _LIGHT_STATUS_PATH: 0},
"on": {_LIGHT_MODE_PATH: 0, _LIGHT_STATUS_PATH: 1},
"auto": {_LIGHT_MODE_PATH: 1},
}
@dataclass(frozen=True, kw_only=True)
class VistapoolSelectEntityDescription(SelectEntityDescription):
"""Describes a Vistapool select entity."""
value_path: str
# A capability flag that must be set, such as main.hasPH.
exists_path: str | tuple[str, ...] | None = None
# A field the controller only reports when it supports the feature. Unlike
# exists_path this is a presence check, so a valid zero still counts.
presence_path: str | None = None
value_map: dict[str, int] | None = None
SELECT_DESCRIPTIONS: tuple[VistapoolSelectEntityDescription, ...] = (
@@ -57,6 +75,15 @@ SELECT_DESCRIPTIONS: tuple[VistapoolSelectEntityDescription, ...] = (
)
for i in (1, 2, 3)
),
VistapoolSelectEntityDescription(
key="light_schedule_frequency",
translation_key="light_schedule_frequency",
entity_category=EntityCategory.CONFIG,
options=list(_LIGHT_FREQUENCIES),
value_path="light.freq",
presence_path="light.freq",
value_map=_LIGHT_FREQUENCIES,
),
)
@@ -74,7 +101,14 @@ def _build_select_entities(
)
if not all(coordinator.get_value(path) for path in required):
continue
if (
description.presence_path is not None
and coordinator.get_value(description.presence_path) is None
):
continue
entities.append(VistapoolSelect(coordinator, description))
if coordinator.get_value(_LIGHT_MODE_PATH) is not None:
entities.append(VistapoolLightModeSelect(coordinator))
return entities
@@ -129,24 +163,31 @@ class VistapoolSelect(VistapoolEntity, SelectEntity):
@override
def current_option(self) -> str | None:
"""Return the option that maps to the current API value."""
index = _to_index(
self.coordinator.get_value(self.entity_description.value_path)
)
options = self.entity_description.options or []
if index is None or not 0 <= index < len(options):
raw = _to_index(self.coordinator.get_value(self.entity_description.value_path))
if raw is None:
return None
return options[index]
if (value_map := self.entity_description.value_map) is not None:
return next(
(option for option, value in value_map.items() if value == raw), None
)
options = self.entity_description.options or []
if not 0 <= raw < len(options):
return None
return options[raw]
@override
async def async_select_option(self, option: str) -> None:
"""Send the index of the chosen option to the controller."""
assert self.entity_description.options is not None
index = self.entity_description.options.index(option)
"""Send the chosen option to the controller."""
if (value_map := self.entity_description.value_map) is not None:
value = value_map[option]
else:
assert self.entity_description.options is not None
value = self.entity_description.options.index(option)
try:
await self.coordinator.api.set_value(
self.coordinator.pool_id,
self.entity_description.value_path,
index,
value,
)
except AquariteError as err:
raise HomeAssistantError(
@@ -154,3 +195,49 @@ class VistapoolSelect(VistapoolEntity, SelectEntity):
translation_key="set_failed",
translation_placeholders={"entity": self.entity_id},
) from err
self.coordinator.apply_optimistic(self.entity_description.value_path, value)
class VistapoolLightModeSelect(VistapoolEntity, SelectEntity):
"""Pool light mode: off, on, or the controller's own schedule.
Off and on need light.mode and light.status written together, so this
writes through set_values rather than the single-value helper.
"""
_attr_translation_key = "light_mode"
_attr_entity_category = EntityCategory.CONFIG
_attr_options = list(_LIGHT_MODE_UPDATES)
def __init__(self, coordinator: VistapoolDataUpdateCoordinator) -> None:
"""Initialize the light mode select entity."""
super().__init__(coordinator)
self._attr_unique_id = self.build_unique_id("light_mode")
@property
@override
def current_option(self) -> str | None:
"""Return auto while the schedule is armed, else the on/off state."""
mode = _to_index(self.coordinator.get_value(_LIGHT_MODE_PATH))
if mode is None:
return None
if mode == 1:
return "auto"
status = _to_index(self.coordinator.get_value(_LIGHT_STATUS_PATH))
if status is None:
return None
return "on" if status == 1 else "off"
@override
async def async_select_option(self, option: str) -> None:
"""Send the option's field set to the controller as one command."""
updates = _LIGHT_MODE_UPDATES[option]
try:
await self.coordinator.api.set_values(self.coordinator.pool_id, updates)
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_values(updates)
@@ -163,6 +163,21 @@
"slow": "[%key:component::vistapool::entity::select::pump_speed::state::slow%]"
}
},
"light_mode": {
"name": "Light mode",
"state": {
"auto": "Auto",
"off": "Off",
"on": "On"
}
},
"light_schedule_frequency": {
"name": "Light schedule frequency",
"state": {
"daily": "Daily",
"weekly": "Weekly"
}
},
"pump_mode": {
"name": "Pump mode",
"state": {
@@ -234,6 +249,12 @@
},
"filtration_interval_start": {
"name": "Filtration interval {number} start"
},
"light_schedule_end": {
"name": "Light schedule end"
},
"light_schedule_start": {
"name": "Light schedule start"
}
}
},
+28 -11
View File
@@ -29,18 +29,32 @@ class VistapoolTimeEntityDescription(TimeEntityDescription):
"""Describes a Vistapool time entity."""
value_path: str
# A field the controller only reports when it supports the feature.
presence_path: str | None = None
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"))
TIME_DESCRIPTIONS: tuple[VistapoolTimeEntityDescription, ...] = (
*(
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"))
),
*(
VistapoolTimeEntityDescription(
key=f"light_schedule_{bound}",
translation_key=f"light_schedule_{bound}",
entity_category=EntityCategory.CONFIG,
value_path=f"light.{api_field}",
presence_path=f"light.{api_field}",
)
for bound, api_field in (("start", "from"), ("end", "to"))
),
)
@@ -49,7 +63,10 @@ def _build_time_entities(
) -> list[TimeEntity]:
"""Build the time entities for a single pool."""
return [
VistapoolTime(coordinator, description) for description in TIME_DESCRIPTIONS
VistapoolTime(coordinator, description)
for description in TIME_DESCRIPTIONS
if description.presence_path is None
or coordinator.get_value(description.presence_path) is not None
]
+309 -2
View File
@@ -1,6 +1,7 @@
"""Tests for the Vistapool select platform."""
from collections.abc import Generator
from copy import deepcopy
from typing import Any
from unittest.mock import AsyncMock, patch
@@ -13,12 +14,17 @@ from homeassistant.components.select import (
DOMAIN as SELECT_DOMAIN,
SERVICE_SELECT_OPTION,
)
from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN, Platform
from homeassistant.const import (
ATTR_ENTITY_ID,
EVENT_STATE_CHANGED,
STATE_UNKNOWN,
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
from tests.common import MockConfigEntry, async_capture_events, snapshot_platform
@pytest.fixture(autouse=True)
@@ -215,3 +221,304 @@ async def test_select_option_raises_on_api_error(
blocking=True,
)
assert excinfo.value.translation_key == "set_failed"
_LIGHT_SCHEDULE_DATA = {
"main": {"version": 1},
"light": {"mode": 1, "status": 0, "freq": 86400, "from": 79200, "to": 3600},
}
async def test_light_selects_not_created_without_scheduling(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_vistapool_client: AsyncMock,
mock_pool_data: dict[str, Any],
) -> None:
"""Test controllers without light scheduling do not get the light selects."""
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("select.my_pool_light_mode") is None
assert hass.states.get("select.my_pool_light_schedule_frequency") is None
@pytest.mark.parametrize(
("mode", "status", "expected"),
[
pytest.param(1, 0, "auto", id="schedule_armed"),
pytest.param(1, 1, "auto", id="schedule_armed_while_on"),
pytest.param(0, 1, "on", id="manual_on"),
pytest.param(0, 0, "off", id="manual_off"),
],
)
async def test_light_mode_current_option(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_vistapool_client: AsyncMock,
mode: int,
status: int,
expected: str,
) -> None:
"""Test the armed schedule wins over the on/off state."""
data = deepcopy(_LIGHT_SCHEDULE_DATA)
data["light"]["mode"] = mode
data["light"]["status"] = status
mock_vistapool_client.fetch_pool_data.return_value = 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("select.my_pool_light_mode").state == expected
@pytest.mark.parametrize(
("option", "expected_updates"),
[
pytest.param("off", {"light.mode": 0, "light.status": 0}, id="off"),
pytest.param("on", {"light.mode": 0, "light.status": 1}, id="on"),
pytest.param("auto", {"light.mode": 1}, id="auto"),
],
)
async def test_light_mode_select_writes_one_command(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_vistapool_client: AsyncMock,
option: str,
expected_updates: dict[str, int],
) -> None:
"""Test each option lands as a single multi-field command.
Writing the fields separately would leave the controller half-applied
between the two commands.
"""
mock_vistapool_client.fetch_pool_data.return_value = deepcopy(_LIGHT_SCHEDULE_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(
SELECT_DOMAIN,
SERVICE_SELECT_OPTION,
{ATTR_ENTITY_ID: "select.my_pool_light_mode", ATTR_OPTION: option},
blocking=True,
)
mock_vistapool_client.set_values.assert_awaited_once_with(
"ABCDEF1234567890", expected_updates
)
@pytest.mark.parametrize(
("raw", "expected"),
[
pytest.param(86400, "daily", id="daily"),
pytest.param(604800, "weekly", id="weekly"),
pytest.param(12345, None, id="unknown_value"),
],
)
async def test_light_schedule_frequency_maps_raw_values(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_vistapool_client: AsyncMock,
raw: int,
expected: str | None,
) -> None:
"""Test the frequency maps by raw seconds, not by option index."""
data = deepcopy(_LIGHT_SCHEDULE_DATA)
data["light"]["freq"] = raw
mock_vistapool_client.fetch_pool_data.return_value = 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()
state = hass.states.get("select.my_pool_light_schedule_frequency").state
assert state == (expected or STATE_UNKNOWN)
async def test_light_schedule_frequency_writes_raw_value(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_vistapool_client: AsyncMock,
) -> None:
"""Test selecting a frequency writes its raw seconds value."""
mock_vistapool_client.fetch_pool_data.return_value = deepcopy(_LIGHT_SCHEDULE_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(
SELECT_DOMAIN,
SERVICE_SELECT_OPTION,
{
ATTR_ENTITY_ID: "select.my_pool_light_schedule_frequency",
ATTR_OPTION: "weekly",
},
blocking=True,
)
mock_vistapool_client.set_value.assert_awaited_once_with(
"ABCDEF1234567890", "light.freq", 604800
)
async def test_select_reflects_choice_before_push(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_vistapool_client: AsyncMock,
mock_pool_data: dict[str, Any],
) -> None:
"""Test a select shows the chosen option without waiting for the push."""
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(
SELECT_DOMAIN,
SERVICE_SELECT_OPTION,
{ATTR_ENTITY_ID: "select.my_pool_pump_speed", ATTR_OPTION: "high"},
blocking=True,
)
assert hass.states.get("select.my_pool_pump_speed").state == "high"
async def test_light_mode_reflects_choice_before_push(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_vistapool_client: AsyncMock,
) -> None:
"""Test the light mode select applies every field of the chosen option."""
mock_vistapool_client.fetch_pool_data.return_value = deepcopy(_LIGHT_SCHEDULE_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("select.my_pool_light_mode").state == "auto"
await hass.services.async_call(
SELECT_DOMAIN,
SERVICE_SELECT_OPTION,
{ATTR_ENTITY_ID: "select.my_pool_light_mode", ATTR_OPTION: "on"},
blocking=True,
)
# Reads back as on only if both light.mode and light.status were applied.
assert hass.states.get("select.my_pool_light_mode").state == "on"
async def test_light_schedule_frequency_reflects_choice_before_push(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_vistapool_client: AsyncMock,
) -> None:
"""Test the frequency select shows the chosen option immediately."""
mock_vistapool_client.fetch_pool_data.return_value = deepcopy(_LIGHT_SCHEDULE_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(
SELECT_DOMAIN,
SERVICE_SELECT_OPTION,
{
ATTR_ENTITY_ID: "select.my_pool_light_schedule_frequency",
ATTR_OPTION: "weekly",
},
blocking=True,
)
assert hass.states.get("select.my_pool_light_schedule_frequency").state == "weekly"
async def test_light_mode_never_publishes_partial_state(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_vistapool_client: AsyncMock,
) -> None:
"""Test leaving auto does not briefly read as another option.
light.mode and light.status both feed current_option, so applying them
one at a time would publish an off state between the two writes.
"""
mock_vistapool_client.fetch_pool_data.return_value = deepcopy(_LIGHT_SCHEDULE_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()
events = async_capture_events(hass, EVENT_STATE_CHANGED)
await hass.services.async_call(
SELECT_DOMAIN,
SERVICE_SELECT_OPTION,
{ATTR_ENTITY_ID: "select.my_pool_light_mode", ATTR_OPTION: "on"},
blocking=True,
)
await hass.async_block_till_done()
states = [
event.data["new_state"].state
for event in events
if event.data["entity_id"] == "select.my_pool_light_mode"
]
assert states == ["on"]
async def test_light_mode_raises_on_api_error(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_vistapool_client: AsyncMock,
) -> None:
"""Test a failed multi-field write raises and leaves the state alone."""
mock_vistapool_client.fetch_pool_data.return_value = deepcopy(_LIGHT_SCHEDULE_DATA)
mock_vistapool_client.set_values.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()
assert hass.states.get("select.my_pool_light_mode").state == "auto"
with pytest.raises(HomeAssistantError) as excinfo:
await hass.services.async_call(
SELECT_DOMAIN,
SERVICE_SELECT_OPTION,
{ATTR_ENTITY_ID: "select.my_pool_light_mode", ATTR_OPTION: "on"},
blocking=True,
)
assert excinfo.value.translation_key == "set_failed"
# The write never reached the controller, so nothing may be applied.
assert hass.states.get("select.my_pool_light_mode").state == "auto"
async def test_light_schedule_frequency_created_for_zero_value(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_vistapool_client: AsyncMock,
) -> None:
"""Test a reported zero still creates the entity.
Zero is a value the controller reports, not a missing field, so it must
surface as an unknown option rather than silently dropping the entity.
"""
data = deepcopy(_LIGHT_SCHEDULE_DATA)
data["light"]["freq"] = 0
mock_vistapool_client.fetch_pool_data.return_value = 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()
state = hass.states.get("select.my_pool_light_schedule_frequency")
assert state is not None
assert state.state == STATE_UNKNOWN
+85
View File
@@ -1,6 +1,7 @@
"""Tests for the Vistapool time platform."""
from collections.abc import Generator
from copy import deepcopy
from typing import Any
from unittest.mock import AsyncMock, patch
@@ -177,3 +178,87 @@ async def test_time_set_value_raises_on_api_error(
blocking=True,
)
assert excinfo.value.translation_key == "set_failed"
_LIGHT_SCHEDULE_DATA = {
"main": {"version": 1},
"light": {"mode": 1, "status": 0, "from": 79200, "to": 3600},
}
async def test_light_schedule_times_not_created_without_scheduling(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_vistapool_client: AsyncMock,
mock_pool_data: dict[str, Any],
) -> None:
"""Test controllers without light scheduling do not get the light times."""
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("time.my_pool_light_schedule_start") is None
assert hass.states.get("time.my_pool_light_schedule_end") is None
async def test_light_schedule_times_decode_seconds(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_vistapool_client: AsyncMock,
) -> None:
"""Test the light schedule bounds decode from seconds since midnight."""
mock_vistapool_client.fetch_pool_data.return_value = deepcopy(_LIGHT_SCHEDULE_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()
# 79200 is 22:00, 3600 is 01:00 the next morning.
assert hass.states.get("time.my_pool_light_schedule_start").state == "22:00:00"
assert hass.states.get("time.my_pool_light_schedule_end").state == "01:00:00"
async def test_light_schedule_time_set_value(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_vistapool_client: AsyncMock,
) -> None:
"""Test setting a light schedule bound writes seconds since midnight."""
mock_vistapool_client.fetch_pool_data.return_value = deepcopy(_LIGHT_SCHEDULE_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: "time.my_pool_light_schedule_start", ATTR_TIME: "21:30:00"},
blocking=True,
)
mock_vistapool_client.set_value.assert_awaited_once_with(
"ABCDEF1234567890", "light.from", 77400
)
async def test_light_schedule_time_created_for_midnight(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_vistapool_client: AsyncMock,
) -> None:
"""Test a schedule bound of zero seconds still creates the entity.
Zero is midnight, a legitimate schedule bound, not a missing field.
"""
data = deepcopy(_LIGHT_SCHEDULE_DATA)
data["light"]["from"] = 0
mock_vistapool_client.fetch_pool_data.return_value = 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("time.my_pool_light_schedule_start").state == "00:00:00"