diff --git a/homeassistant/components/vistapool/__init__.py b/homeassistant/components/vistapool/__init__.py index 51e0eb585330da..50230ab7686e86 100644 --- a/homeassistant/components/vistapool/__init__.py +++ b/homeassistant/components/vistapool/__init__.py @@ -16,7 +16,7 @@ _LOGGER = logging.getLogger(__name__) -PLATFORMS: list[Platform] = [Platform.SENSOR] +PLATFORMS: list[Platform] = [Platform.BUTTON, Platform.SENSOR] @dataclass diff --git a/homeassistant/components/vistapool/button.py b/homeassistant/components/vistapool/button.py new file mode 100644 index 00000000000000..2432dc505ad044 --- /dev/null +++ b/homeassistant/components/vistapool/button.py @@ -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"): + await self.coordinator.api.set_value( + self.coordinator.pool_id, _LIGHT_STATUS_PATH, 0 + ) + await asyncio.sleep(_LED_PULSE_DELAY_SECONDS) + await self.coordinator.api.set_value( + self.coordinator.pool_id, _LIGHT_STATUS_PATH, 1 + ) + except AquariteError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="set_failed", + translation_placeholders={"entity": self.entity_id}, + ) from err + # 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) diff --git a/homeassistant/components/vistapool/quality_scale.yaml b/homeassistant/components/vistapool/quality_scale.yaml index 72ca9758594650..4ffcc19d98ae7f 100644 --- a/homeassistant/components/vistapool/quality_scale.yaml +++ b/homeassistant/components/vistapool/quality_scale.yaml @@ -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 @@ -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 @@ -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 diff --git a/homeassistant/components/vistapool/strings.json b/homeassistant/components/vistapool/strings.json index 1ef44f92ec3e5c..5ceceab013be61 100644 --- a/homeassistant/components/vistapool/strings.json +++ b/homeassistant/components/vistapool/strings.json @@ -25,6 +25,11 @@ } }, "entity": { + "button": { + "led_pulse": { + "name": "LED next color" + } + }, "sensor": { "chlorine": { "name": "Chlorine" @@ -59,6 +64,9 @@ "no_pools": { "message": "No pools were found on this account." }, + "set_failed": { + "message": "Failed to set {entity}." + }, "update_failed": { "message": "Error fetching data from Vistapool." } diff --git a/tests/components/vistapool/snapshots/test_button.ambr b/tests/components/vistapool/snapshots/test_button.ambr new file mode 100644 index 00000000000000..a79bb0822310d7 --- /dev/null +++ b/tests/components/vistapool/snapshots/test_button.ambr @@ -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': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + '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': , + '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': , + 'entity_id': 'button.my_pool_led_next_color', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/vistapool/test_button.py b/tests/components/vistapool/test_button.py new file mode 100644 index 00000000000000..30a0e1122dd6c2 --- /dev/null +++ b/tests/components/vistapool/test_button.py @@ -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]: + """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]: + """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( + 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"