Add Run Program Buttons in Rainbird (#183013)

This commit is contained in:
Pavan
2026-09-30 07:18:36 +02:00
committed by GitHub
parent 0efa3eabc5
commit 19946e869c
5 changed files with 236 additions and 0 deletions
@@ -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,
@@ -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()
@@ -5,6 +5,11 @@
"default": "mdi:water"
}
},
"button": {
"run_program": {
"default": "mdi:sprinkler-variant"
}
},
"calendar": {
"calendar": {
"default": "mdi:sprinkler"
@@ -39,6 +39,11 @@
"name": "Rainsensor"
}
},
"button": {
"run_program": {
"name": "Run {program}"
}
},
"number": {
"rain_delay": {
"name": "Rain delay"
+163
View File
@@ -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)