mirror of
https://github.com/home-assistant/core.git
synced 2026-09-05 04:51:05 +01:00
Add pool light scheduling to Vistapool (#180686)
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user