From fa2d77407a7e6c1806a4798f215d436f2ed0aad5 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Mon, 25 Sep 2023 17:27:38 -0700 Subject: [PATCH] Add Rain Bird irrigation calendar (#87604) * Initial version of a calendar for the rainbird integration * Improve calendar support * Revert changes to test fixtures * Address ruff error * Fix background task scheduling * Use pytest.mark.freezetime to move to test setup * Address PR feedback * Make refresh a member * Merge rainbird and calendar changes * Increase test coverage * Readability improvements * Simplify timezone handling --- homeassistant/components/rainbird/__init__.py | 24 +- .../components/rainbird/binary_sensor.py | 2 +- homeassistant/components/rainbird/calendar.py | 118 ++++++++ .../components/rainbird/coordinator.py | 75 ++++- homeassistant/components/rainbird/number.py | 2 +- homeassistant/components/rainbird/sensor.py | 2 +- homeassistant/components/rainbird/switch.py | 2 +- tests/components/rainbird/conftest.py | 11 +- tests/components/rainbird/test_calendar.py | 272 ++++++++++++++++++ tests/components/rainbird/test_number.py | 2 +- tests/components/rainbird/test_switch.py | 1 - 11 files changed, 488 insertions(+), 23 deletions(-) create mode 100644 homeassistant/components/rainbird/calendar.py create mode 100644 tests/components/rainbird/test_calendar.py diff --git a/homeassistant/components/rainbird/__init__.py b/homeassistant/components/rainbird/__init__.py index 2af0cb30f1e..a97af14f449 100644 --- a/homeassistant/components/rainbird/__init__.py +++ b/homeassistant/components/rainbird/__init__.py @@ -10,10 +10,15 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import CONF_SERIAL_NUMBER -from .coordinator import RainbirdUpdateCoordinator +from .coordinator import RainbirdData -PLATFORMS = [Platform.SWITCH, Platform.SENSOR, Platform.BINARY_SENSOR, Platform.NUMBER] +PLATFORMS = [ + Platform.SWITCH, + Platform.SENSOR, + Platform.BINARY_SENSOR, + Platform.NUMBER, + Platform.CALENDAR, +] DOMAIN = "rainbird" @@ -35,16 +40,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: model_info = await controller.get_model_and_version() except RainbirdApiException as err: raise ConfigEntryNotReady from err - coordinator = RainbirdUpdateCoordinator( - hass, - name=entry.title, - controller=controller, - serial_number=entry.data[CONF_SERIAL_NUMBER], - model_info=model_info, - ) - await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + data = RainbirdData(hass, entry, controller, model_info) + await data.coordinator.async_config_entry_first_refresh() + + hass.data[DOMAIN][entry.entry_id] = data await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/rainbird/binary_sensor.py b/homeassistant/components/rainbird/binary_sensor.py index 139a17f5181..b5886011ea3 100644 --- a/homeassistant/components/rainbird/binary_sensor.py +++ b/homeassistant/components/rainbird/binary_sensor.py @@ -31,7 +31,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up entry for a Rain Bird binary_sensor.""" - coordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = hass.data[DOMAIN][config_entry.entry_id].coordinator async_add_entities([RainBirdSensor(coordinator, RAIN_SENSOR_ENTITY_DESCRIPTION)]) diff --git a/homeassistant/components/rainbird/calendar.py b/homeassistant/components/rainbird/calendar.py new file mode 100644 index 00000000000..4d8cc38c8bf --- /dev/null +++ b/homeassistant/components/rainbird/calendar.py @@ -0,0 +1,118 @@ +"""Rain Bird irrigation calendar.""" + +from __future__ import annotations + +from datetime import datetime +import logging + +from homeassistant.components.calendar import CalendarEntity, CalendarEvent +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.util import dt as dt_util + +from .const import DOMAIN +from .coordinator import RainbirdScheduleUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up entry for a Rain Bird irrigation calendar.""" + data = hass.data[DOMAIN][config_entry.entry_id] + if not data.model_info.model_info.max_programs: + return + + async_add_entities( + [ + RainBirdCalendarEntity( + data.schedule_coordinator, + data.coordinator.serial_number, + data.coordinator.device_info, + ) + ] + ) + + +class RainBirdCalendarEntity( + CoordinatorEntity[RainbirdScheduleUpdateCoordinator], CalendarEntity +): + """A calendar event entity.""" + + _attr_has_entity_name = True + _attr_name = None + _attr_icon = "mdi:sprinkler" + + def __init__( + self, + coordinator: RainbirdScheduleUpdateCoordinator, + serial_number: str, + device_info: DeviceInfo, + ) -> None: + """Create the Calendar event device.""" + super().__init__(coordinator) + self._event: CalendarEvent | None = None + self._attr_unique_id = serial_number + self._attr_device_info = device_info + + @property + def event(self) -> CalendarEvent | None: + """Return the next upcoming event.""" + schedule = self.coordinator.data + if not schedule: + return None + cursor = schedule.timeline_tz(dt_util.DEFAULT_TIME_ZONE).active_after( + dt_util.now() + ) + program_event = next(cursor, None) + if not program_event: + return None + return CalendarEvent( + summary=program_event.program_id.name, + start=dt_util.as_local(program_event.start), + end=dt_util.as_local(program_event.end), + rrule=program_event.rrule_str, + ) + + async def async_get_events( + self, hass: HomeAssistant, start_date: datetime, end_date: datetime + ) -> list[CalendarEvent]: + """Get all events in a specific time frame.""" + schedule = self.coordinator.data + if not schedule: + raise HomeAssistantError( + "Unable to get events: No data from controller yet" + ) + cursor = schedule.timeline_tz(start_date.tzinfo).overlapping( + start_date, + end_date, + ) + return [ + CalendarEvent( + summary=program_event.program_id.name, + start=dt_util.as_local(program_event.start), + end=dt_util.as_local(program_event.end), + rrule=program_event.rrule_str, + ) + for program_event in cursor + ] + + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + await super().async_added_to_hass() + + # We do not ask for an update with async_add_entities() + # because it will update disabled entities. This is started as a + # task to let it sync in the background without blocking startup + self.coordinator.config_entry.async_create_background_task( + self.hass, + self.coordinator.async_request_refresh(), + "rainbird.calendar-refresh", + ) diff --git a/homeassistant/components/rainbird/coordinator.py b/homeassistant/components/rainbird/coordinator.py index cac86d8c928..d61f9140771 100644 --- a/homeassistant/components/rainbird/coordinator.py +++ b/homeassistant/components/rainbird/coordinator.py @@ -5,23 +5,29 @@ from __future__ import annotations import asyncio from dataclasses import dataclass import datetime +from functools import cached_property import logging from typing import TypeVar +import async_timeout from pyrainbird.async_client import ( AsyncRainbirdController, RainbirdApiException, RainbirdDeviceBusyException, ) -from pyrainbird.data import ModelAndVersion +from pyrainbird.data import ModelAndVersion, Schedule +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import DOMAIN, MANUFACTURER, TIMEOUT_SECONDS +from .const import CONF_SERIAL_NUMBER, DOMAIN, MANUFACTURER, TIMEOUT_SECONDS UPDATE_INTERVAL = datetime.timedelta(minutes=1) +# The calendar data requires RPCs for each program/zone, and the data rarely +# changes, so we refresh it less often. +CALENDAR_UPDATE_INTERVAL = datetime.timedelta(minutes=15) _LOGGER = logging.getLogger(__name__) @@ -49,7 +55,7 @@ class RainbirdUpdateCoordinator(DataUpdateCoordinator[RainbirdDeviceState]): serial_number: str, model_info: ModelAndVersion, ) -> None: - """Initialize ZoneStateUpdateCoordinator.""" + """Initialize RainbirdUpdateCoordinator.""" super().__init__( hass, _LOGGER, @@ -108,3 +114,66 @@ class RainbirdUpdateCoordinator(DataUpdateCoordinator[RainbirdDeviceState]): rain=rain, rain_delay=rain_delay, ) + + +class RainbirdScheduleUpdateCoordinator(DataUpdateCoordinator[Schedule]): + """Coordinator for rainbird irrigation schedule calls.""" + + config_entry: ConfigEntry + + def __init__( + self, + hass: HomeAssistant, + name: str, + controller: AsyncRainbirdController, + ) -> None: + """Initialize ZoneStateUpdateCoordinator.""" + super().__init__( + hass, + _LOGGER, + name=name, + update_method=self._async_update_data, + update_interval=CALENDAR_UPDATE_INTERVAL, + ) + self._controller = controller + + async def _async_update_data(self) -> Schedule: + """Fetch data from Rain Bird device.""" + try: + async with async_timeout.timeout(TIMEOUT_SECONDS): + return await self._controller.get_schedule() + except RainbirdApiException as err: + raise UpdateFailed(f"Error communicating with Device: {err}") from err + + +@dataclass +class RainbirdData: + """Holder for shared integration data. + + The coordinators are lazy since they may only be used by some platforms when needed. + """ + + hass: HomeAssistant + entry: ConfigEntry + controller: AsyncRainbirdController + model_info: ModelAndVersion + + @cached_property + def coordinator(self) -> RainbirdUpdateCoordinator: + """Return RainbirdUpdateCoordinator.""" + return RainbirdUpdateCoordinator( + self.hass, + name=self.entry.title, + controller=self.controller, + serial_number=self.entry.data[CONF_SERIAL_NUMBER], + model_info=self.model_info, + ) + + @cached_property + def schedule_coordinator(self) -> RainbirdScheduleUpdateCoordinator: + """Return RainbirdScheduleUpdateCoordinator.""" + return RainbirdScheduleUpdateCoordinator( + self.hass, + name=f"{self.entry.title} Schedule", + controller=self.controller, + ) diff --git a/homeassistant/components/rainbird/number.py b/homeassistant/components/rainbird/number.py index de049f921dd..d0945609a1b 100644 --- a/homeassistant/components/rainbird/number.py +++ b/homeassistant/components/rainbird/number.py @@ -28,7 +28,7 @@ async def async_setup_entry( async_add_entities( [ RainDelayNumber( - hass.data[DOMAIN][config_entry.entry_id], + hass.data[DOMAIN][config_entry.entry_id].coordinator, ) ] ) diff --git a/homeassistant/components/rainbird/sensor.py b/homeassistant/components/rainbird/sensor.py index f5cf2390095..32eb053f478 100644 --- a/homeassistant/components/rainbird/sensor.py +++ b/homeassistant/components/rainbird/sensor.py @@ -32,7 +32,7 @@ async def async_setup_entry( async_add_entities( [ RainBirdSensor( - hass.data[DOMAIN][config_entry.entry_id], + hass.data[DOMAIN][config_entry.entry_id].coordinator, RAIN_DELAY_ENTITY_DESCRIPTION, ) ] diff --git a/homeassistant/components/rainbird/switch.py b/homeassistant/components/rainbird/switch.py index 39bb4a7b0d1..cafc541d860 100644 --- a/homeassistant/components/rainbird/switch.py +++ b/homeassistant/components/rainbird/switch.py @@ -33,7 +33,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up entry for a Rain Bird irrigation switches.""" - coordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = hass.data[DOMAIN][config_entry.entry_id].coordinator async_add_entities( RainBirdSwitch( coordinator, diff --git a/tests/components/rainbird/conftest.py b/tests/components/rainbird/conftest.py index 40b400210aa..dbc3456117c 100644 --- a/tests/components/rainbird/conftest.py +++ b/tests/components/rainbird/conftest.py @@ -37,7 +37,7 @@ SERIAL_NUMBER = 0x12635436566 SERIAL_RESPONSE = "850000012635436566" ZERO_SERIAL_RESPONSE = "850000000000000000" # Model and version command 0x82 -MODEL_AND_VERSION_RESPONSE = "820006090C" +MODEL_AND_VERSION_RESPONSE = "820005090C" # ESP-TM2 # Get available stations command 0x83 AVAILABLE_STATIONS_RESPONSE = "83017F000000" # Mask for 7 zones EMPTY_STATIONS_RESPONSE = "830000000000" @@ -184,8 +184,15 @@ def mock_rain_delay_response() -> str: return RAIN_DELAY_OFF +@pytest.fixture(name="model_and_version_response") +def mock_model_and_version_response() -> str: + """Mock response to return rain delay state.""" + return MODEL_AND_VERSION_RESPONSE + + @pytest.fixture(name="api_responses") def mock_api_responses( + model_and_version_response: str, stations_response: str, zone_state_response: str, rain_response: str, @@ -196,7 +203,7 @@ def mock_api_responses( These are returned in the order they are requested by the update coordinator. """ return [ - MODEL_AND_VERSION_RESPONSE, + model_and_version_response, stations_response, zone_state_response, rain_response, diff --git a/tests/components/rainbird/test_calendar.py b/tests/components/rainbird/test_calendar.py new file mode 100644 index 00000000000..2028fccc24f --- /dev/null +++ b/tests/components/rainbird/test_calendar.py @@ -0,0 +1,272 @@ +"""Tests for rainbird calendar platform.""" + + +from collections.abc import Awaitable, Callable +import datetime +from http import HTTPStatus +from typing import Any +import urllib +from zoneinfo import ZoneInfo + +from aiohttp import ClientSession +from freezegun.api import FrozenDateTimeFactory +import pytest + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .conftest import ComponentSetup, mock_response, mock_response_error + +from tests.test_util.aiohttp import AiohttpClientMockResponse + +TEST_ENTITY = "calendar.rain_bird_controller" +GetEventsFn = Callable[[str, str], Awaitable[dict[str, Any]]] + +SCHEDULE_RESPONSES = [ + # Current controller status + "A0000000000000", + # Per-program information + "A00010060602006400", # CUSTOM: Monday & Tuesday + "A00011110602006400", + "A00012000300006400", + # Start times per program + "A0006000F0FFFFFFFFFFFF", # 4am + "A00061FFFFFFFFFFFFFFFF", + "A00062FFFFFFFFFFFFFFFF", + # Run times for each zone + "A00080001900000000001400000000", # zone1=25, zone2=20 + "A00081000700000000001400000000", # zone3=7, zone4=20 + "A00082000A00000000000000000000", # zone5=10 + "A00083000000000000000000000000", + "A00084000000000000000000000000", + "A00085000000000000000000000000", + "A00086000000000000000000000000", + "A00087000000000000000000000000", + "A00088000000000000000000000000", + "A00089000000000000000000000000", + "A0008A000000000000000000000000", +] + +EMPTY_SCHEDULE_RESPONSES = [ + # Current controller status + "A0000000000000", + # Per-program information (ignored) + "A00010000000000000", + "A00011000000000000", + "A00012000000000000", + # Start times for each program (off) + "A00060FFFFFFFFFFFFFFFF", + "A00061FFFFFFFFFFFFFFFF", + "A00062FFFFFFFFFFFFFFFF", + # Run times for each zone + "A00080000000000000000000000000", + "A00081000000000000000000000000", + "A00082000000000000000000000000", + "A00083000000000000000000000000", + "A00084000000000000000000000000", + "A00085000000000000000000000000", + "A00086000000000000000000000000", + "A00087000000000000000000000000", + "A00088000000000000000000000000", + "A00089000000000000000000000000", + "A0008A000000000000000000000000", +] + + +@pytest.fixture +def platforms() -> list[str]: + """Fixture to specify platforms to test.""" + return [Platform.CALENDAR] + + +@pytest.fixture(autouse=True) +def set_time_zone(hass: HomeAssistant): + """Set the time zone for the tests.""" + hass.config.set_time_zone("America/Regina") + + +@pytest.fixture(autouse=True) +def mock_schedule_responses() -> list[str]: + """Fixture containing fake irrigation schedule.""" + return SCHEDULE_RESPONSES + + +@pytest.fixture(autouse=True) +def mock_insert_schedule_response( + mock_schedule_responses: list[str], responses: list[AiohttpClientMockResponse] +) -> None: + """Fixture to insert device responses for the irrigation schedule.""" + responses.extend( + [mock_response(api_response) for api_response in mock_schedule_responses] + ) + + +@pytest.fixture(name="get_events") +def get_events_fixture( + hass_client: Callable[..., Awaitable[ClientSession]] +) -> GetEventsFn: + """Fetch calendar events from the HTTP API.""" + + async def _fetch(start: str, end: str) -> list[dict[str, Any]]: + client = await hass_client() + response = await client.get( + f"/api/calendars/{TEST_ENTITY}?start={urllib.parse.quote(start)}&end={urllib.parse.quote(end)}" + ) + assert response.status == HTTPStatus.OK + results = await response.json() + return [{k: event[k] for k in {"summary", "start", "end"}} for event in results] + + return _fetch + + +@pytest.mark.freeze_time("2023-01-21 09:32:00") +async def test_get_events( + hass: HomeAssistant, setup_integration: ComponentSetup, get_events: GetEventsFn +) -> None: + """Test calendar event fetching APIs.""" + + assert await setup_integration() + + events = await get_events("2023-01-20T00:00:00Z", "2023-02-05T00:00:00Z") + assert events == [ + # Monday + { + "summary": "PGM A", + "start": {"dateTime": "2023-01-23T04:00:00-06:00"}, + "end": {"dateTime": "2023-01-23T05:22:00-06:00"}, + }, + # Tuesday + { + "summary": "PGM A", + "start": {"dateTime": "2023-01-24T04:00:00-06:00"}, + "end": {"dateTime": "2023-01-24T05:22:00-06:00"}, + }, + # Monday + { + "summary": "PGM A", + "start": {"dateTime": "2023-01-30T04:00:00-06:00"}, + "end": {"dateTime": "2023-01-30T05:22:00-06:00"}, + }, + # Tuesday + { + "summary": "PGM A", + "start": {"dateTime": "2023-01-31T04:00:00-06:00"}, + "end": {"dateTime": "2023-01-31T05:22:00-06:00"}, + }, + ] + + +@pytest.mark.parametrize( + ("freeze_time", "expected_state"), + [ + ( + datetime.datetime(2023, 1, 23, 3, 50, tzinfo=ZoneInfo("America/Regina")), + "off", + ), + ( + datetime.datetime(2023, 1, 23, 4, 30, tzinfo=ZoneInfo("America/Regina")), + "on", + ), + ], +) +async def test_event_state( + hass: HomeAssistant, + setup_integration: ComponentSetup, + get_events: GetEventsFn, + freezer: FrozenDateTimeFactory, + freeze_time: datetime.datetime, + expected_state: str, +) -> None: + """Test calendar upcoming event state.""" + freezer.move_to(freeze_time) + + assert await setup_integration() + + state = hass.states.get(TEST_ENTITY) + assert state is not None + assert state.attributes == { + "message": "PGM A", + "start_time": "2023-01-23 04:00:00", + "end_time": "2023-01-23 05:22:00", + "all_day": False, + "description": "", + "location": "", + "friendly_name": "Rain Bird Controller", + "icon": "mdi:sprinkler", + } + assert state.state == expected_state + + +@pytest.mark.parametrize( + ("model_and_version_response", "has_entity"), + [ + ("820005090C", True), + ("820006090C", False), + ], + ids=("ESP-TM2", "ST8x-WiFi"), +) +async def test_calendar_not_supported_by_device( + hass: HomeAssistant, + setup_integration: ComponentSetup, + has_entity: bool, +) -> None: + """Test calendar upcoming event state.""" + + assert await setup_integration() + + state = hass.states.get(TEST_ENTITY) + assert (state is not None) == has_entity + + +@pytest.mark.parametrize( + "mock_insert_schedule_response", [([None])] # Disable success responses +) +async def test_no_schedule( + hass: HomeAssistant, + setup_integration: ComponentSetup, + get_events: GetEventsFn, + responses: list[AiohttpClientMockResponse], + hass_client: Callable[..., Awaitable[ClientSession]], +) -> None: + """Test calendar error when fetching the calendar.""" + responses.extend([mock_response_error(HTTPStatus.BAD_GATEWAY)]) # Arbitrary error + + assert await setup_integration() + + state = hass.states.get(TEST_ENTITY) + assert state.state == "unavailable" + assert state.attributes == { + "friendly_name": "Rain Bird Controller", + "icon": "mdi:sprinkler", + } + + client = await hass_client() + response = await client.get( + f"/api/calendars/{TEST_ENTITY}?start=2023-08-01&end=2023-08-02" + ) + assert response.status == HTTPStatus.INTERNAL_SERVER_ERROR + + +@pytest.mark.freeze_time("2023-01-21 09:32:00") +@pytest.mark.parametrize( + "mock_schedule_responses", + [(EMPTY_SCHEDULE_RESPONSES)], +) +async def test_program_schedule_disabled( + hass: HomeAssistant, + setup_integration: ComponentSetup, + get_events: GetEventsFn, +) -> None: + """Test calendar when the program is disabled with no upcoming events.""" + + assert await setup_integration() + + events = await get_events("2023-01-20T00:00:00Z", "2023-02-05T00:00:00Z") + assert events == [] + + state = hass.states.get(TEST_ENTITY) + assert state.state == "off" + assert state.attributes == { + "friendly_name": "Rain Bird Controller", + "icon": "mdi:sprinkler", + } diff --git a/tests/components/rainbird/test_number.py b/tests/components/rainbird/test_number.py index 2c837a75c66..6ce7d10c9f2 100644 --- a/tests/components/rainbird/test_number.py +++ b/tests/components/rainbird/test_number.py @@ -73,7 +73,7 @@ async def test_set_value( device = device_registry.async_get_device(identifiers={(DOMAIN, SERIAL_NUMBER)}) assert device assert device.name == "Rain Bird Controller" - assert device.model == "ST8x-WiFi" + assert device.model == "ESP-TM2" assert device.sw_version == "9.12" aioclient_mock.mock_calls.clear() diff --git a/tests/components/rainbird/test_switch.py b/tests/components/rainbird/test_switch.py index 9127a0b0c61..9ce5e799c92 100644 --- a/tests/components/rainbird/test_switch.py +++ b/tests/components/rainbird/test_switch.py @@ -57,7 +57,6 @@ async def test_no_zones( async def test_zones( hass: HomeAssistant, setup_integration: ComponentSetup, - responses: list[AiohttpClientMockResponse], ) -> None: """Test switch platform with fake data that creates 7 zones with one enabled."""