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:
parent
18f29993c5
commit
fa2d77407a
11 changed files with 488 additions and 23 deletions
|
@ -10,10 +10,15 @@ from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.exceptions import ConfigEntryNotReady
|
from homeassistant.exceptions import ConfigEntryNotReady
|
||||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||||
|
|
||||||
from .const import CONF_SERIAL_NUMBER
|
from .coordinator import RainbirdData
|
||||||
from .coordinator import RainbirdUpdateCoordinator
|
|
||||||
|
|
||||||
PLATFORMS = [Platform.SWITCH, Platform.SENSOR, Platform.BINARY_SENSOR, Platform.NUMBER]
|
PLATFORMS = [
|
||||||
|
Platform.SWITCH,
|
||||||
|
Platform.SENSOR,
|
||||||
|
Platform.BINARY_SENSOR,
|
||||||
|
Platform.NUMBER,
|
||||||
|
Platform.CALENDAR,
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
DOMAIN = "rainbird"
|
DOMAIN = "rainbird"
|
||||||
|
@ -35,16 +40,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
model_info = await controller.get_model_and_version()
|
model_info = await controller.get_model_and_version()
|
||||||
except RainbirdApiException as err:
|
except RainbirdApiException as err:
|
||||||
raise ConfigEntryNotReady from 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)
|
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||||
|
|
||||||
|
|
|
@ -31,7 +31,7 @@ async def async_setup_entry(
|
||||||
async_add_entities: AddEntitiesCallback,
|
async_add_entities: AddEntitiesCallback,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Set up entry for a Rain Bird binary_sensor."""
|
"""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)])
|
async_add_entities([RainBirdSensor(coordinator, RAIN_SENSOR_ENTITY_DESCRIPTION)])
|
||||||
|
|
||||||
|
|
||||||
|
|
118
homeassistant/components/rainbird/calendar.py
Normal file
118
homeassistant/components/rainbird/calendar.py
Normal 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",
|
||||||
|
)
|
|
@ -5,23 +5,29 @@ from __future__ import annotations
|
||||||
import asyncio
|
import asyncio
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
import datetime
|
import datetime
|
||||||
|
from functools import cached_property
|
||||||
import logging
|
import logging
|
||||||
from typing import TypeVar
|
from typing import TypeVar
|
||||||
|
|
||||||
|
import async_timeout
|
||||||
from pyrainbird.async_client import (
|
from pyrainbird.async_client import (
|
||||||
AsyncRainbirdController,
|
AsyncRainbirdController,
|
||||||
RainbirdApiException,
|
RainbirdApiException,
|
||||||
RainbirdDeviceBusyException,
|
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.core import HomeAssistant
|
||||||
from homeassistant.helpers.device_registry import DeviceInfo
|
from homeassistant.helpers.device_registry import DeviceInfo
|
||||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
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)
|
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__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
@ -49,7 +55,7 @@ class RainbirdUpdateCoordinator(DataUpdateCoordinator[RainbirdDeviceState]):
|
||||||
serial_number: str,
|
serial_number: str,
|
||||||
model_info: ModelAndVersion,
|
model_info: ModelAndVersion,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Initialize ZoneStateUpdateCoordinator."""
|
"""Initialize RainbirdUpdateCoordinator."""
|
||||||
super().__init__(
|
super().__init__(
|
||||||
hass,
|
hass,
|
||||||
_LOGGER,
|
_LOGGER,
|
||||||
|
@ -108,3 +114,66 @@ class RainbirdUpdateCoordinator(DataUpdateCoordinator[RainbirdDeviceState]):
|
||||||
rain=rain,
|
rain=rain,
|
||||||
rain_delay=rain_delay,
|
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,
|
||||||
|
)
|
||||||
|
|
|
@ -28,7 +28,7 @@ async def async_setup_entry(
|
||||||
async_add_entities(
|
async_add_entities(
|
||||||
[
|
[
|
||||||
RainDelayNumber(
|
RainDelayNumber(
|
||||||
hass.data[DOMAIN][config_entry.entry_id],
|
hass.data[DOMAIN][config_entry.entry_id].coordinator,
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
|
@ -32,7 +32,7 @@ async def async_setup_entry(
|
||||||
async_add_entities(
|
async_add_entities(
|
||||||
[
|
[
|
||||||
RainBirdSensor(
|
RainBirdSensor(
|
||||||
hass.data[DOMAIN][config_entry.entry_id],
|
hass.data[DOMAIN][config_entry.entry_id].coordinator,
|
||||||
RAIN_DELAY_ENTITY_DESCRIPTION,
|
RAIN_DELAY_ENTITY_DESCRIPTION,
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
|
|
@ -33,7 +33,7 @@ async def async_setup_entry(
|
||||||
async_add_entities: AddEntitiesCallback,
|
async_add_entities: AddEntitiesCallback,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Set up entry for a Rain Bird irrigation switches."""
|
"""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(
|
async_add_entities(
|
||||||
RainBirdSwitch(
|
RainBirdSwitch(
|
||||||
coordinator,
|
coordinator,
|
||||||
|
|
|
@ -37,7 +37,7 @@ SERIAL_NUMBER = 0x12635436566
|
||||||
SERIAL_RESPONSE = "850000012635436566"
|
SERIAL_RESPONSE = "850000012635436566"
|
||||||
ZERO_SERIAL_RESPONSE = "850000000000000000"
|
ZERO_SERIAL_RESPONSE = "850000000000000000"
|
||||||
# Model and version command 0x82
|
# Model and version command 0x82
|
||||||
MODEL_AND_VERSION_RESPONSE = "820006090C"
|
MODEL_AND_VERSION_RESPONSE = "820005090C" # ESP-TM2
|
||||||
# Get available stations command 0x83
|
# Get available stations command 0x83
|
||||||
AVAILABLE_STATIONS_RESPONSE = "83017F000000" # Mask for 7 zones
|
AVAILABLE_STATIONS_RESPONSE = "83017F000000" # Mask for 7 zones
|
||||||
EMPTY_STATIONS_RESPONSE = "830000000000"
|
EMPTY_STATIONS_RESPONSE = "830000000000"
|
||||||
|
@ -184,8 +184,15 @@ def mock_rain_delay_response() -> str:
|
||||||
return RAIN_DELAY_OFF
|
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")
|
@pytest.fixture(name="api_responses")
|
||||||
def mock_api_responses(
|
def mock_api_responses(
|
||||||
|
model_and_version_response: str,
|
||||||
stations_response: str,
|
stations_response: str,
|
||||||
zone_state_response: str,
|
zone_state_response: str,
|
||||||
rain_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.
|
These are returned in the order they are requested by the update coordinator.
|
||||||
"""
|
"""
|
||||||
return [
|
return [
|
||||||
MODEL_AND_VERSION_RESPONSE,
|
model_and_version_response,
|
||||||
stations_response,
|
stations_response,
|
||||||
zone_state_response,
|
zone_state_response,
|
||||||
rain_response,
|
rain_response,
|
||||||
|
|
272
tests/components/rainbird/test_calendar.py
Normal file
272
tests/components/rainbird/test_calendar.py
Normal 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",
|
||||||
|
}
|
|
@ -73,7 +73,7 @@ async def test_set_value(
|
||||||
device = device_registry.async_get_device(identifiers={(DOMAIN, SERIAL_NUMBER)})
|
device = device_registry.async_get_device(identifiers={(DOMAIN, SERIAL_NUMBER)})
|
||||||
assert device
|
assert device
|
||||||
assert device.name == "Rain Bird Controller"
|
assert device.name == "Rain Bird Controller"
|
||||||
assert device.model == "ST8x-WiFi"
|
assert device.model == "ESP-TM2"
|
||||||
assert device.sw_version == "9.12"
|
assert device.sw_version == "9.12"
|
||||||
|
|
||||||
aioclient_mock.mock_calls.clear()
|
aioclient_mock.mock_calls.clear()
|
||||||
|
|
|
@ -57,7 +57,6 @@ async def test_no_zones(
|
||||||
async def test_zones(
|
async def test_zones(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
setup_integration: ComponentSetup,
|
setup_integration: ComponentSetup,
|
||||||
responses: list[AiohttpClientMockResponse],
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test switch platform with fake data that creates 7 zones with one enabled."""
|
"""Test switch platform with fake data that creates 7 zones with one enabled."""
|
||||||
|
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue