diff --git a/homeassistant/components/rainbird/__init__.py b/homeassistant/components/rainbird/__init__.py index 3d0a8b2b2f03..0aab7106cd06 100644 --- a/homeassistant/components/rainbird/__init__.py +++ b/homeassistant/components/rainbird/__init__.py @@ -39,6 +39,7 @@ _LOGGER = logging.getLogger(__name__) CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) PLATFORMS = [ Platform.BINARY_SENSOR, + Platform.BUTTON, Platform.CALENDAR, Platform.NUMBER, Platform.SENSOR, diff --git a/homeassistant/components/rainbird/button.py b/homeassistant/components/rainbird/button.py new file mode 100644 index 000000000000..3d78a197652c --- /dev/null +++ b/homeassistant/components/rainbird/button.py @@ -0,0 +1,62 @@ +"""The button platform for rainbird.""" + +from typing import override + +from pyrainbird.exceptions import RainbirdApiException, RainbirdDeviceBusyException +from pyrainbird.timeline import ProgramId + +from homeassistant.components.button import ButtonEntity +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .coordinator import RainbirdUpdateCoordinator +from .types import RainbirdConfigEntry + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: RainbirdConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up entry for a Rain Bird button platform.""" + data = config_entry.runtime_data + async_add_entities( + RainbirdProgramButton(data.coordinator, program) + for program in range(data.model_info.model_info.max_programs) + ) + + +class RainbirdProgramButton(CoordinatorEntity[RainbirdUpdateCoordinator], ButtonEntity): + """A button that manually starts a program configured on the controller.""" + + _attr_translation_key = "run_program" + _attr_has_entity_name = True + + def __init__(self, coordinator: RainbirdUpdateCoordinator, program: int) -> None: + """Initialize the Rain Bird program button.""" + super().__init__(coordinator) + self._program = program + program_name = ProgramId(program).name + self._attr_translation_placeholders = {"program": program_name} + if coordinator.unique_id is not None: + self._attr_unique_id = f"{coordinator.unique_id}-program-{program}" + self._attr_device_info = coordinator.device_info + else: + self._attr_name = f"{coordinator.device_name} Run {program_name}" + + @override + async def async_press(self) -> None: + """Start the program.""" + try: + await self.coordinator.controller.set_program(self._program) + except RainbirdDeviceBusyException as err: + raise HomeAssistantError( + "Rain Bird device is busy; Wait and try again" + ) from err + except RainbirdApiException as err: + raise HomeAssistantError("Rain Bird device failure") from err + + # Zone switches reflect the running program after the next refresh. + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/rainbird/icons.json b/homeassistant/components/rainbird/icons.json index d8fd2afbfd0f..dcf2ea47b7fc 100644 --- a/homeassistant/components/rainbird/icons.json +++ b/homeassistant/components/rainbird/icons.json @@ -5,6 +5,11 @@ "default": "mdi:water" } }, + "button": { + "run_program": { + "default": "mdi:sprinkler-variant" + } + }, "calendar": { "calendar": { "default": "mdi:sprinkler" diff --git a/homeassistant/components/rainbird/strings.json b/homeassistant/components/rainbird/strings.json index 2d7857b152d5..37c61a183a0f 100644 --- a/homeassistant/components/rainbird/strings.json +++ b/homeassistant/components/rainbird/strings.json @@ -39,6 +39,11 @@ "name": "Rainsensor" } }, + "button": { + "run_program": { + "name": "Run {program}" + } + }, "number": { "rain_delay": { "name": "Rain delay" diff --git a/tests/components/rainbird/test_button.py b/tests/components/rainbird/test_button.py new file mode 100644 index 000000000000..6cfc83ed70d4 --- /dev/null +++ b/tests/components/rainbird/test_button.py @@ -0,0 +1,163 @@ +"""Tests for rainbird button platform.""" + +from http import HTTPStatus +import json + +from pyrainbird import encryption +import pytest + +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.config_entries import ConfigEntryState +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 .conftest import ( + ACK_ECHO, + CONFIG_ENTRY_DATA_OLD_FORMAT, + PASSWORD, + mock_response, + mock_response_error, +) + +from tests.common import MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMocker, AiohttpClientMockResponse + +PROGRAM_A_ENTITY_ID = "button.rain_bird_controller_run_pgm_a" + + +@pytest.fixture +def platforms() -> list[Platform]: + """Fixture to specify platforms to test.""" + return [Platform.BUTTON] + + +@pytest.fixture(autouse=True) +async def setup_config_entry( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """Fixture to setup the config entry.""" + await hass.config_entries.async_setup(config_entry.entry_id) + assert config_entry.state is ConfigEntryState.LOADED + + +@pytest.mark.parametrize( + ("program", "unique_id"), + [ + pytest.param("a", "4c:a1:61:00:11:22-program-0", id="program_a"), + pytest.param("b", "4c:a1:61:00:11:22-program-1", id="program_b"), + pytest.param("c", "4c:a1:61:00:11:22-program-2", id="program_c"), + ], +) +async def test_program_buttons( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + program: str, + unique_id: str, +) -> None: + """Test a button is created for each program supported by the ESP-TM2.""" + entity_id = f"button.rain_bird_controller_run_pgm_{program}" + state = hass.states.get(entity_id) + assert state is not None + assert ( + state.attributes["friendly_name"] + == f"Rain Bird Controller Run PGM {program.upper()}" + ) + + entity_entry = entity_registry.async_get(entity_id) + assert entity_entry + assert entity_entry.unique_id == unique_id + + +async def test_program_count(hass: HomeAssistant) -> None: + """Test no buttons exist beyond the model's program limit.""" + assert len(hass.states.async_entity_ids(BUTTON_DOMAIN)) == 3 + + +@pytest.mark.parametrize( + ("program", "command"), + [ + pytest.param("a", "3800", id="program_a"), + pytest.param("b", "3801", id="program_b"), + pytest.param("c", "3802", id="program_c"), + ], +) +async def test_press( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + responses: list[AiohttpClientMockResponse], + program: str, + command: str, +) -> None: + """Test pressing a button starts its program.""" + aioclient_mock.mock_calls.clear() + responses.append(mock_response(ACK_ECHO)) + + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: f"button.rain_bird_controller_run_pgm_{program}"}, + blocking=True, + ) + + assert len(aioclient_mock.mock_calls) == 1 + payload = encryption.decrypt(aioclient_mock.mock_calls[0][2], PASSWORD) + request = json.loads(payload.decode().rstrip("\x00")) + assert request["params"]["data"] == command + + +@pytest.mark.parametrize( + ("status", "expected_msg"), + [ + pytest.param( + HTTPStatus.SERVICE_UNAVAILABLE, "Rain Bird device is busy", id="busy" + ), + pytest.param( + HTTPStatus.INTERNAL_SERVER_ERROR, "Rain Bird device failure", id="failure" + ), + ], +) +async def test_press_error( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + responses: list[AiohttpClientMockResponse], + status: HTTPStatus, + expected_msg: str, +) -> None: + """Test an error while talking to the device.""" + aioclient_mock.mock_calls.clear() + responses.append(mock_response_error(status=status)) + + with pytest.raises(HomeAssistantError, match=expected_msg): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: PROGRAM_A_ENTITY_ID}, + blocking=True, + ) + + assert len(aioclient_mock.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("config_entry_data", "config_entry_unique_id", "setup_config_entry"), + [(CONFIG_ENTRY_DATA_OLD_FORMAT, None, None)], +) +async def test_no_unique_id( + hass: HomeAssistant, + responses: list[AiohttpClientMockResponse], + entity_registry: er.EntityRegistry, + config_entry: MockConfigEntry, +) -> None: + """Test button platform with no unique id.""" + # Failure to migrate config entry to a unique id + responses.insert(1, mock_response_error(HTTPStatus.SERVICE_UNAVAILABLE)) + + await hass.config_entries.async_setup(config_entry.entry_id) + assert config_entry.state is ConfigEntryState.LOADED + + state = hass.states.get(PROGRAM_A_ENTITY_ID) + assert state is not None + assert state.attributes["friendly_name"] == "Rain Bird Controller Run PGM A" + assert not entity_registry.async_get(PROGRAM_A_ENTITY_ID)