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
+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"