Skip to content

Commit d8b02ea

Browse files
fdebrusclaude
andauthored
Add button platform to Vistapool (home-assistant#172550)
Co-authored-by: Claude <noreply@anthropic.com>
1 parent 36d2e85 commit d8b02ea

6 files changed

Lines changed: 333 additions & 6 deletions

File tree

homeassistant/components/vistapool/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616

1717
_LOGGER = logging.getLogger(__name__)
1818

19-
PLATFORMS: list[Platform] = [Platform.SENSOR]
19+
PLATFORMS: list[Platform] = [Platform.BUTTON, Platform.SENSOR]
2020

2121

2222
@dataclass
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
"""Vistapool Button entities."""
2+
3+
import asyncio
4+
5+
from aioaquarite import AquariteError
6+
7+
from homeassistant.components.button import ButtonEntity
8+
from homeassistant.core import HomeAssistant
9+
from homeassistant.exceptions import HomeAssistantError
10+
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
11+
12+
from . import VistapoolConfigEntry
13+
from .const import DOMAIN
14+
from .coordinator import VistapoolDataUpdateCoordinator
15+
from .entity import VistapoolEntity
16+
17+
PARALLEL_UPDATES = 1
18+
19+
_HASLED_PATH = "main.hasLED"
20+
_LIGHT_STATUS_PATH = "light.status"
21+
_LED_PULSE_DELAY_SECONDS = 1.0
22+
23+
24+
async def async_setup_entry(
25+
hass: HomeAssistant,
26+
entry: VistapoolConfigEntry,
27+
async_add_entities: AddConfigEntryEntitiesCallback,
28+
) -> None:
29+
"""Set up Vistapool buttons for every pool that has an LED fixture."""
30+
async_add_entities(
31+
VistapoolLEDPulseButton(coordinator)
32+
for coordinator in entry.runtime_data.coordinators.values()
33+
if coordinator.get_value(_HASLED_PATH)
34+
)
35+
36+
37+
class VistapoolLEDPulseButton(VistapoolEntity, ButtonEntity):
38+
"""Power-cycle the pool light to advance the LED fixture's color.
39+
40+
Mirrors the "Next" button under LED Color in the Vistapool app's
41+
Illumination screen. If the light is on, sends light.status=0, waits a
42+
moment, then light.status=1; the physical LED fixture advances to the
43+
next color on power-on. If the light is off, just turns it on.
44+
"""
45+
46+
_attr_translation_key = "led_pulse"
47+
48+
def __init__(self, coordinator: VistapoolDataUpdateCoordinator) -> None:
49+
"""Initialize the LED pulse button."""
50+
super().__init__(coordinator)
51+
self._attr_unique_id = self.build_unique_id("led_pulse")
52+
53+
async def async_press(self) -> None:
54+
"""Send a color-advance pulse to the pool LED fixture."""
55+
try:
56+
if self.coordinator.get_value(_LIGHT_STATUS_PATH) in (True, "1"):
57+
await self.coordinator.api.set_value(
58+
self.coordinator.pool_id, _LIGHT_STATUS_PATH, 0
59+
)
60+
await asyncio.sleep(_LED_PULSE_DELAY_SECONDS)
61+
await self.coordinator.api.set_value(
62+
self.coordinator.pool_id, _LIGHT_STATUS_PATH, 1
63+
)
64+
except AquariteError as err:
65+
raise HomeAssistantError(
66+
translation_domain=DOMAIN,
67+
translation_key="set_failed",
68+
translation_placeholders={"entity": self.entity_id},
69+
) from err
70+
# Optimistically reflect the just-written value so a rapid second press
71+
# doesn't read the stale off-state before the Firestore push round-trips.
72+
self.coordinator.data.setdefault("light", {})["status"] = 1
73+
self.coordinator.async_set_updated_data(self.coordinator.data)

homeassistant/components/vistapool/quality_scale.yaml

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ rules:
22
# Bronze
33
action-setup:
44
status: exempt
5-
comment: No service actions in initial sensor-only platform
5+
comment: No integration-specific service actions; entities use platform-standard actions only
66
appropriate-polling: done
77
brands: done
88
common-modules: done
@@ -11,7 +11,7 @@ rules:
1111
dependency-transparency: done
1212
docs-actions:
1313
status: exempt
14-
comment: No service actions in initial sensor-only platform
14+
comment: No integration-specific service actions to document
1515
docs-high-level-description: done
1616
docs-installation-instructions: done
1717
docs-removal-instructions: done
@@ -24,9 +24,7 @@ rules:
2424
unique-config-entry: done
2525

2626
# Silver
27-
action-exceptions:
28-
status: exempt
29-
comment: No user actions (sensor-only platform)
27+
action-exceptions: done
3028
config-entry-unloading: done
3129
docs-configuration-parameters:
3230
status: exempt

homeassistant/components/vistapool/strings.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,11 @@
2525
}
2626
},
2727
"entity": {
28+
"button": {
29+
"led_pulse": {
30+
"name": "LED next color"
31+
}
32+
},
2833
"sensor": {
2934
"chlorine": {
3035
"name": "Chlorine"
@@ -59,6 +64,9 @@
5964
"no_pools": {
6065
"message": "No pools were found on this account."
6166
},
67+
"set_failed": {
68+
"message": "Failed to set {entity}."
69+
},
6270
"update_failed": {
6371
"message": "Error fetching data from Vistapool."
6472
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# serializer version: 1
2+
# name: test_all_entities[button.my_pool_led_next_color-entry]
3+
EntityRegistryEntrySnapshot({
4+
'aliases': list([
5+
None,
6+
]),
7+
'area_id': None,
8+
'capabilities': None,
9+
'config_entry_id': <ANY>,
10+
'config_subentry_id': <ANY>,
11+
'device_class': None,
12+
'device_id': <ANY>,
13+
'disabled_by': None,
14+
'domain': 'button',
15+
'entity_category': None,
16+
'entity_id': 'button.my_pool_led_next_color',
17+
'has_entity_name': True,
18+
'hidden_by': None,
19+
'icon': None,
20+
'id': <ANY>,
21+
'labels': set({
22+
}),
23+
'name': None,
24+
'object_id_base': 'LED next color',
25+
'options': dict({
26+
}),
27+
'original_device_class': None,
28+
'original_icon': None,
29+
'original_name': 'LED next color',
30+
'platform': 'vistapool',
31+
'previous_unique_id': None,
32+
'suggested_object_id': None,
33+
'supported_features': 0,
34+
'translation_key': 'led_pulse',
35+
'unique_id': 'ABCDEF1234567890-led_pulse',
36+
'unit_of_measurement': None,
37+
})
38+
# ---
39+
# name: test_all_entities[button.my_pool_led_next_color-state]
40+
StateSnapshot({
41+
'attributes': ReadOnlyDict({
42+
'friendly_name': 'My Pool LED next color',
43+
}),
44+
'context': <ANY>,
45+
'entity_id': 'button.my_pool_led_next_color',
46+
'last_changed': <ANY>,
47+
'last_reported': <ANY>,
48+
'last_updated': <ANY>,
49+
'state': 'unknown',
50+
})
51+
# ---
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
"""Tests for the Vistapool button platform."""
2+
3+
from collections.abc import Generator
4+
from typing import Any
5+
from unittest.mock import AsyncMock, patch
6+
7+
from aioaquarite import AquariteError
8+
import pytest
9+
from syrupy.assertion import SnapshotAssertion
10+
11+
from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS
12+
from homeassistant.const import ATTR_ENTITY_ID, Platform
13+
from homeassistant.core import HomeAssistant
14+
from homeassistant.exceptions import HomeAssistantError
15+
from homeassistant.helpers import entity_registry as er
16+
17+
from tests.common import MockConfigEntry, snapshot_platform
18+
19+
_BUTTON = "button.my_pool_led_next_color"
20+
_LED_DATA = {"main": {"hasLED": 1, "version": 1}, "light": {"status": 0}}
21+
22+
23+
@pytest.fixture(autouse=True)
24+
def _only_button_platform() -> Generator[None]:
25+
"""Restrict integration setup to the button platform for these tests."""
26+
with patch("homeassistant.components.vistapool.PLATFORMS", [Platform.BUTTON]):
27+
yield
28+
29+
30+
@pytest.fixture(autouse=True)
31+
def _skip_pulse_delay() -> Generator[None]:
32+
"""Skip the LED pulse delay so tests don't actually sleep."""
33+
with patch("homeassistant.components.vistapool.button._LED_PULSE_DELAY_SECONDS", 0):
34+
yield
35+
36+
37+
async def test_all_entities(
38+
hass: HomeAssistant,
39+
snapshot: SnapshotAssertion,
40+
entity_registry: er.EntityRegistry,
41+
mock_config_entry: MockConfigEntry,
42+
mock_vistapool_client: AsyncMock,
43+
) -> None:
44+
"""Test the LED-pulse button when hasLED is set."""
45+
mock_vistapool_client.fetch_pool_data.return_value = _LED_DATA
46+
mock_config_entry.add_to_hass(hass)
47+
48+
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
49+
await hass.async_block_till_done()
50+
51+
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
52+
53+
54+
async def test_button_not_created_without_led(
55+
hass: HomeAssistant,
56+
mock_config_entry: MockConfigEntry,
57+
mock_vistapool_client: AsyncMock,
58+
mock_pool_data: dict[str, Any],
59+
) -> None:
60+
"""Test the LED-pulse button is not created when hasLED is 0."""
61+
mock_vistapool_client.fetch_pool_data.return_value = mock_pool_data
62+
mock_config_entry.add_to_hass(hass)
63+
64+
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
65+
await hass.async_block_till_done()
66+
67+
assert hass.states.get(_BUTTON) is None
68+
69+
70+
async def test_button_press_when_light_off(
71+
hass: HomeAssistant,
72+
mock_config_entry: MockConfigEntry,
73+
mock_vistapool_client: AsyncMock,
74+
) -> None:
75+
"""Test pressing the button when the light is off just turns it on."""
76+
mock_vistapool_client.fetch_pool_data.return_value = _LED_DATA
77+
mock_config_entry.add_to_hass(hass)
78+
79+
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
80+
await hass.async_block_till_done()
81+
82+
await hass.services.async_call(
83+
BUTTON_DOMAIN,
84+
SERVICE_PRESS,
85+
{ATTR_ENTITY_ID: _BUTTON},
86+
blocking=True,
87+
)
88+
89+
mock_vistapool_client.set_value.assert_awaited_once_with(
90+
"ABCDEF1234567890", "light.status", 1
91+
)
92+
93+
94+
async def test_button_press_when_light_on(
95+
hass: HomeAssistant,
96+
mock_config_entry: MockConfigEntry,
97+
mock_vistapool_client: AsyncMock,
98+
) -> None:
99+
"""Test pressing the button when the light is on power-cycles it."""
100+
mock_vistapool_client.fetch_pool_data.return_value = {
101+
"main": {"hasLED": 1, "version": 1},
102+
"light": {"status": 1},
103+
}
104+
mock_config_entry.add_to_hass(hass)
105+
106+
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
107+
await hass.async_block_till_done()
108+
109+
await hass.services.async_call(
110+
BUTTON_DOMAIN,
111+
SERVICE_PRESS,
112+
{ATTR_ENTITY_ID: _BUTTON},
113+
blocking=True,
114+
)
115+
116+
assert mock_vistapool_client.set_value.await_count == 2
117+
assert mock_vistapool_client.set_value.await_args_list[0].args == (
118+
"ABCDEF1234567890",
119+
"light.status",
120+
0,
121+
)
122+
assert mock_vistapool_client.set_value.await_args_list[1].args == (
123+
"ABCDEF1234567890",
124+
"light.status",
125+
1,
126+
)
127+
128+
129+
async def test_button_press_rapid_repeat_after_off(
130+
hass: HomeAssistant,
131+
mock_config_entry: MockConfigEntry,
132+
mock_vistapool_client: AsyncMock,
133+
) -> None:
134+
"""Test a second press lands the off/on pulse instead of repeating turn-on.
135+
136+
Without the optimistic update, the second press would read the stale
137+
off-state (the Firestore push hasn't round-tripped yet) and send another
138+
bare light.status=1 — a no-op on the wire that doesn't advance the color.
139+
"""
140+
mock_vistapool_client.fetch_pool_data.return_value = _LED_DATA
141+
mock_config_entry.add_to_hass(hass)
142+
143+
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
144+
await hass.async_block_till_done()
145+
146+
await hass.services.async_call(
147+
BUTTON_DOMAIN,
148+
SERVICE_PRESS,
149+
{ATTR_ENTITY_ID: _BUTTON},
150+
blocking=True,
151+
)
152+
await hass.services.async_call(
153+
BUTTON_DOMAIN,
154+
SERVICE_PRESS,
155+
{ATTR_ENTITY_ID: _BUTTON},
156+
blocking=True,
157+
)
158+
159+
assert mock_vistapool_client.set_value.await_count == 3
160+
assert mock_vistapool_client.set_value.await_args_list[0].args == (
161+
"ABCDEF1234567890",
162+
"light.status",
163+
1,
164+
)
165+
assert mock_vistapool_client.set_value.await_args_list[1].args == (
166+
"ABCDEF1234567890",
167+
"light.status",
168+
0,
169+
)
170+
assert mock_vistapool_client.set_value.await_args_list[2].args == (
171+
"ABCDEF1234567890",
172+
"light.status",
173+
1,
174+
)
175+
176+
177+
async def test_button_press_raises_on_api_error(
178+
hass: HomeAssistant,
179+
mock_config_entry: MockConfigEntry,
180+
mock_vistapool_client: AsyncMock,
181+
) -> None:
182+
"""Test the button re-raises HomeAssistantError when the library fails."""
183+
mock_vistapool_client.fetch_pool_data.return_value = _LED_DATA
184+
mock_vistapool_client.set_value.side_effect = AquariteError("boom")
185+
mock_config_entry.add_to_hass(hass)
186+
187+
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
188+
await hass.async_block_till_done()
189+
190+
with pytest.raises(HomeAssistantError) as excinfo:
191+
await hass.services.async_call(
192+
BUTTON_DOMAIN,
193+
SERVICE_PRESS,
194+
{ATTR_ENTITY_ID: _BUTTON},
195+
blocking=True,
196+
)
197+
assert excinfo.value.translation_key == "set_failed"

0 commit comments

Comments
 (0)