From f23cda199d883baef8430da5b5e371eebba4fe01 Mon Sep 17 00:00:00 2001 From: fdebrus <33791533+fdebrus@users.noreply.github.com> Date: Sun, 30 Aug 2026 16:48:42 +0200 Subject: [PATCH] Add pool light scheduling to Vistapool (#180686) Co-authored-by: Claude --- .../components/vistapool/coordinator.py | 28 +- homeassistant/components/vistapool/icons.json | 14 + homeassistant/components/vistapool/select.py | 107 +++++- .../components/vistapool/strings.json | 21 ++ homeassistant/components/vistapool/time.py | 39 ++- tests/components/vistapool/test_select.py | 311 +++++++++++++++++- tests/components/vistapool/test_time.py | 85 +++++ 7 files changed, 573 insertions(+), 32 deletions(-) diff --git a/homeassistant/components/vistapool/coordinator.py b/homeassistant/components/vistapool/coordinator.py index ad24ebf636bf..6a59cc11dcfb 100644 --- a/homeassistant/components/vistapool/coordinator.py +++ b/homeassistant/components/vistapool/coordinator.py @@ -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) diff --git a/homeassistant/components/vistapool/icons.json b/homeassistant/components/vistapool/icons.json index 4746854e1541..ac31757b4b28 100644 --- a/homeassistant/components/vistapool/icons.json +++ b/homeassistant/components/vistapool/icons.json @@ -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" } } } diff --git a/homeassistant/components/vistapool/select.py b/homeassistant/components/vistapool/select.py index f37d62573c6e..b7101cbcef65 100644 --- a/homeassistant/components/vistapool/select.py +++ b/homeassistant/components/vistapool/select.py @@ -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) diff --git a/homeassistant/components/vistapool/strings.json b/homeassistant/components/vistapool/strings.json index 16fb5fa407af..8d829e4abafd 100644 --- a/homeassistant/components/vistapool/strings.json +++ b/homeassistant/components/vistapool/strings.json @@ -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" } } }, diff --git a/homeassistant/components/vistapool/time.py b/homeassistant/components/vistapool/time.py index a3ada79de863..f0da17b8c07e 100644 --- a/homeassistant/components/vistapool/time.py +++ b/homeassistant/components/vistapool/time.py @@ -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 ] diff --git a/tests/components/vistapool/test_select.py b/tests/components/vistapool/test_select.py index c1872ca0f2d9..853c660be761 100644 --- a/tests/components/vistapool/test_select.py +++ b/tests/components/vistapool/test_select.py @@ -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 diff --git a/tests/components/vistapool/test_time.py b/tests/components/vistapool/test_time.py index 09ecbf0ff4b9..605f35e8cc2e 100644 --- a/tests/components/vistapool/test_time.py +++ b/tests/components/vistapool/test_time.py @@ -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"