Skip to content
Merged
2 changes: 1 addition & 1 deletion homeassistant/components/vistapool/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@

_LOGGER = logging.getLogger(__name__)

PLATFORMS: list[Platform] = [Platform.SENSOR]
PLATFORMS: list[Platform] = [Platform.BUTTON, Platform.SENSOR]


@dataclass
Expand Down
73 changes: 73 additions & 0 deletions homeassistant/components/vistapool/button.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
"""Vistapool Button entities."""

import asyncio

from aioaquarite import AquariteError

from homeassistant.components.button import ButtonEntity
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback

from . import VistapoolConfigEntry
from .const import DOMAIN
from .coordinator import VistapoolDataUpdateCoordinator
from .entity import VistapoolEntity

PARALLEL_UPDATES = 1

_HASLED_PATH = "main.hasLED"
_LIGHT_STATUS_PATH = "light.status"
_LED_PULSE_DELAY_SECONDS = 1.0


async def async_setup_entry(
hass: HomeAssistant,
entry: VistapoolConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Vistapool buttons for every pool that has an LED fixture."""
async_add_entities(
VistapoolLEDPulseButton(coordinator)
for coordinator in entry.runtime_data.coordinators.values()
if coordinator.get_value(_HASLED_PATH)
)


class VistapoolLEDPulseButton(VistapoolEntity, ButtonEntity):
"""Power-cycle the pool light to advance the LED fixture's color.

Mirrors the "Next" button under LED Color in the Vistapool app's
Illumination screen. If the light is on, sends light.status=0, waits a
moment, then light.status=1; the physical LED fixture advances to the
next color on power-on. If the light is off, just turns it on.
"""

_attr_translation_key = "led_pulse"

def __init__(self, coordinator: VistapoolDataUpdateCoordinator) -> None:
"""Initialize the LED pulse button."""
super().__init__(coordinator)
self._attr_unique_id = self.build_unique_id("led_pulse")

async def async_press(self) -> None:
"""Send a color-advance pulse to the pool LED fixture."""
try:
if self.coordinator.get_value(_LIGHT_STATUS_PATH) in (True, "1"):
Comment thread
fdebrus marked this conversation as resolved.
Comment thread
fdebrus marked this conversation as resolved.
Comment thread
fdebrus marked this conversation as resolved.
await self.coordinator.api.set_value(
self.coordinator.pool_id, _LIGHT_STATUS_PATH, 0
)
await asyncio.sleep(_LED_PULSE_DELAY_SECONDS)
Comment thread
fdebrus marked this conversation as resolved.
await self.coordinator.api.set_value(
self.coordinator.pool_id, _LIGHT_STATUS_PATH, 1
)
Comment thread
fdebrus marked this conversation as resolved.
Comment thread
fdebrus marked this conversation as resolved.
except AquariteError as err:
Comment thread
fdebrus marked this conversation as resolved.
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="set_failed",
translation_placeholders={"entity": self.entity_id},
) from err
Comment thread
fdebrus marked this conversation as resolved.
# Optimistically reflect the just-written value so a rapid second press
# doesn't read the stale off-state before the Firestore push round-trips.
self.coordinator.data.setdefault("light", {})["status"] = 1
self.coordinator.async_set_updated_data(self.coordinator.data)
Comment thread
fdebrus marked this conversation as resolved.
Comment thread
fdebrus marked this conversation as resolved.
8 changes: 3 additions & 5 deletions homeassistant/components/vistapool/quality_scale.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ rules:
# Bronze
action-setup:
status: exempt
comment: No service actions in initial sensor-only platform
comment: No integration-specific service actions; entities use platform-standard actions only
appropriate-polling: done
brands: done
common-modules: done
Expand All @@ -11,7 +11,7 @@ rules:
dependency-transparency: done
docs-actions:
status: exempt
comment: No service actions in initial sensor-only platform
comment: No integration-specific service actions to document
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
Expand All @@ -24,9 +24,7 @@ rules:
unique-config-entry: done

# Silver
action-exceptions:
status: exempt
comment: No user actions (sensor-only platform)
action-exceptions: done
config-entry-unloading: done
docs-configuration-parameters:
status: exempt
Expand Down
8 changes: 8 additions & 0 deletions homeassistant/components/vistapool/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@
}
},
"entity": {
"button": {
"led_pulse": {
"name": "LED next color"
}
},
"sensor": {
"chlorine": {
"name": "Chlorine"
Expand Down Expand Up @@ -59,6 +64,9 @@
"no_pools": {
"message": "No pools were found on this account."
},
"set_failed": {
"message": "Failed to set {entity}."
},
Comment thread
fdebrus marked this conversation as resolved.
Comment thread
fdebrus marked this conversation as resolved.
Comment thread
fdebrus marked this conversation as resolved.
"update_failed": {
"message": "Error fetching data from Vistapool."
}
Expand Down
51 changes: 51 additions & 0 deletions tests/components/vistapool/snapshots/test_button.ambr
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# serializer version: 1
# name: test_all_entities[button.my_pool_led_next_color-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'button',
'entity_category': None,
'entity_id': 'button.my_pool_led_next_color',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'LED next color',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'LED next color',
'platform': 'vistapool',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'led_pulse',
'unique_id': 'ABCDEF1234567890-led_pulse',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[button.my_pool_led_next_color-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'My Pool LED next color',
}),
'context': <ANY>,
'entity_id': 'button.my_pool_led_next_color',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
197 changes: 197 additions & 0 deletions tests/components/vistapool/test_button.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
"""Tests for the Vistapool button platform."""

from collections.abc import Generator
from typing import Any
from unittest.mock import AsyncMock, patch

from aioaquarite import AquariteError
import pytest
from syrupy.assertion import SnapshotAssertion

from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS
from homeassistant.const import ATTR_ENTITY_ID, 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

_BUTTON = "button.my_pool_led_next_color"
_LED_DATA = {"main": {"hasLED": 1, "version": 1}, "light": {"status": 0}}


@pytest.fixture(autouse=True)
def _only_button_platform() -> Generator[None]:
Comment thread
fdebrus marked this conversation as resolved.
"""Restrict integration setup to the button platform for these tests."""
with patch("homeassistant.components.vistapool.PLATFORMS", [Platform.BUTTON]):
yield


@pytest.fixture(autouse=True)
def _skip_pulse_delay() -> Generator[None]:
Comment thread
fdebrus marked this conversation as resolved.
"""Skip the LED pulse delay so tests don't actually sleep."""
with patch("homeassistant.components.vistapool.button._LED_PULSE_DELAY_SECONDS", 0):
yield


async def test_all_entities(
Comment thread
fdebrus marked this conversation as resolved.
hass: HomeAssistant,
snapshot: SnapshotAssertion,
entity_registry: er.EntityRegistry,
mock_config_entry: MockConfigEntry,
mock_vistapool_client: AsyncMock,
) -> None:
"""Test the LED-pulse button when hasLED is set."""
mock_vistapool_client.fetch_pool_data.return_value = _LED_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 snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)


async def test_button_not_created_without_led(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_vistapool_client: AsyncMock,
mock_pool_data: dict[str, Any],
) -> None:
"""Test the LED-pulse button is not created when hasLED is 0."""
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(_BUTTON) is None


async def test_button_press_when_light_off(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_vistapool_client: AsyncMock,
) -> None:
"""Test pressing the button when the light is off just turns it on."""
mock_vistapool_client.fetch_pool_data.return_value = _LED_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(
BUTTON_DOMAIN,
SERVICE_PRESS,
{ATTR_ENTITY_ID: _BUTTON},
blocking=True,
)

mock_vistapool_client.set_value.assert_awaited_once_with(
"ABCDEF1234567890", "light.status", 1
)


async def test_button_press_when_light_on(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_vistapool_client: AsyncMock,
) -> None:
"""Test pressing the button when the light is on power-cycles it."""
mock_vistapool_client.fetch_pool_data.return_value = {
"main": {"hasLED": 1, "version": 1},
"light": {"status": 1},
}
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(
BUTTON_DOMAIN,
SERVICE_PRESS,
{ATTR_ENTITY_ID: _BUTTON},
blocking=True,
)

assert mock_vistapool_client.set_value.await_count == 2
assert mock_vistapool_client.set_value.await_args_list[0].args == (
"ABCDEF1234567890",
"light.status",
0,
)
assert mock_vistapool_client.set_value.await_args_list[1].args == (
"ABCDEF1234567890",
"light.status",
1,
)


async def test_button_press_rapid_repeat_after_off(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_vistapool_client: AsyncMock,
) -> None:
"""Test a second press lands the off/on pulse instead of repeating turn-on.

Without the optimistic update, the second press would read the stale
off-state (the Firestore push hasn't round-tripped yet) and send another
bare light.status=1 — a no-op on the wire that doesn't advance the color.
"""
mock_vistapool_client.fetch_pool_data.return_value = _LED_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(
BUTTON_DOMAIN,
SERVICE_PRESS,
{ATTR_ENTITY_ID: _BUTTON},
blocking=True,
)
await hass.services.async_call(
BUTTON_DOMAIN,
SERVICE_PRESS,
{ATTR_ENTITY_ID: _BUTTON},
blocking=True,
)

assert mock_vistapool_client.set_value.await_count == 3
assert mock_vistapool_client.set_value.await_args_list[0].args == (
"ABCDEF1234567890",
"light.status",
1,
)
assert mock_vistapool_client.set_value.await_args_list[1].args == (
"ABCDEF1234567890",
"light.status",
0,
)
assert mock_vistapool_client.set_value.await_args_list[2].args == (
"ABCDEF1234567890",
"light.status",
1,
)


async def test_button_press_raises_on_api_error(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_vistapool_client: AsyncMock,
) -> None:
"""Test the button re-raises HomeAssistantError when the library fails."""
mock_vistapool_client.fetch_pool_data.return_value = _LED_DATA
mock_vistapool_client.set_value.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()

with pytest.raises(HomeAssistantError) as excinfo:
await hass.services.async_call(
BUTTON_DOMAIN,
SERVICE_PRESS,
{ATTR_ENTITY_ID: _BUTTON},
blocking=True,
)
assert excinfo.value.translation_key == "set_failed"
Loading