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
This commit is contained in:
Allen Porter 2023-09-25 17:27:38 -07:00 committed by GitHub
parent 18f29993c5
commit fa2d77407a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 488 additions and 23 deletions

View File

@ -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)

View File

@ -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)])

View File

@ -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",
)

View File

@ -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,
)

View File

@ -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,
)
]
)

View File

@ -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,
)
]

View File

@ -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,

View File

@ -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,

View File

@ -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",
}

View File

@ -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()

View File

@ -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."""