Add calendar for Rachio smart hose timer (#120030)

This commit is contained in:
Brian Rogers 2024-07-20 10:38:51 -04:00 committed by GitHub
parent 0f079454bb
commit 63b0feeae7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 283 additions and 12 deletions

View File

@ -23,7 +23,7 @@ from .webhooks import (
_LOGGER = logging.getLogger(__name__)
PLATFORMS = [Platform.BINARY_SENSOR, Platform.SWITCH]
PLATFORMS = [Platform.BINARY_SENSOR, Platform.CALENDAR, Platform.SWITCH]
CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False)
@ -96,7 +96,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
)
for base in person.base_stations:
await base.coordinator.async_config_entry_first_refresh()
await base.status_coordinator.async_config_entry_first_refresh()
await base.schedule_coordinator.async_config_entry_first_refresh()
# Enable platform
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = person

View File

@ -60,9 +60,9 @@ def _create_entities(hass: HomeAssistant, config_entry: ConfigEntry) -> list[Ent
entities.append(RachioControllerOnlineBinarySensor(controller))
entities.append(RachioRainSensor(controller))
entities.extend(
RachioHoseTimerBattery(valve, base_station.coordinator)
RachioHoseTimerBattery(valve, base_station.status_coordinator)
for base_station in person.base_stations
for valve in base_station.coordinator.data.values()
for valve in base_station.status_coordinator.data.values()
)
return entities

View File

@ -0,0 +1,177 @@
"""Rachio smart hose timer calendar."""
from datetime import datetime, timedelta
import logging
from typing import Any
from homeassistant.components.calendar import (
CalendarEntity,
CalendarEntityFeature,
CalendarEvent,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
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 as DOMAIN_RACHIO,
KEY_ADDRESS,
KEY_DURATION_SECONDS,
KEY_ID,
KEY_LOCALITY,
KEY_PROGRAM_ID,
KEY_PROGRAM_NAME,
KEY_RUN_SUMMARIES,
KEY_SERIAL_NUMBER,
KEY_SKIP,
KEY_SKIPPABLE,
KEY_START_TIME,
KEY_TOTAL_RUN_DURATION,
KEY_VALVE_NAME,
)
from .coordinator import RachioScheduleUpdateCoordinator
from .device import RachioPerson
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up entry for Rachio smart hose timer calendar."""
person: RachioPerson = hass.data[DOMAIN_RACHIO][config_entry.entry_id]
async_add_entities(
RachioCalendarEntity(base_station.schedule_coordinator, base_station)
for base_station in person.base_stations
)
class RachioCalendarEntity(
CoordinatorEntity[RachioScheduleUpdateCoordinator], CalendarEntity
):
"""Rachio calendar entity."""
_attr_has_entity_name = True
_attr_translation_key = "calendar"
_attr_supported_features = CalendarEntityFeature.DELETE_EVENT
def __init__(
self, coordinator: RachioScheduleUpdateCoordinator, base_station
) -> None:
"""Initialize a Rachio calendar entity."""
super().__init__(coordinator)
self.base_station = base_station
self._event: CalendarEvent | None = None
self._location = coordinator.base_station[KEY_ADDRESS][KEY_LOCALITY]
self._attr_translation_placeholders = {
"base": coordinator.base_station[KEY_SERIAL_NUMBER]
}
self._attr_unique_id = f"{coordinator.base_station[KEY_ID]}-calendar"
self._previous_event: dict[str, Any] | None = None
@property
def event(self) -> CalendarEvent | None:
"""Return the next upcoming event."""
if not (event := self._handle_upcoming_event()):
return None
start_time = dt_util.parse_datetime(event[KEY_START_TIME], raise_on_error=True)
valves = ", ".join(
[event[KEY_VALVE_NAME] for event in event[KEY_RUN_SUMMARIES]]
)
return CalendarEvent(
summary=event[KEY_PROGRAM_NAME],
start=dt_util.as_local(start_time),
end=dt_util.as_local(start_time)
+ timedelta(seconds=int(event[KEY_TOTAL_RUN_DURATION])),
description=valves,
location=self._location,
)
def _handle_upcoming_event(self) -> dict[str, Any] | None:
"""Handle current or next event."""
# Currently when an event starts, it disappears from the
# API until the event ends. So we store the upcoming event and use
# the stored version if it's within the event time window.
if self._previous_event:
start_time = dt_util.parse_datetime(
self._previous_event[KEY_START_TIME], raise_on_error=True
)
end_time = start_time + timedelta(
seconds=int(self._previous_event[KEY_TOTAL_RUN_DURATION])
)
if start_time <= dt_util.now() <= end_time:
return self._previous_event
schedule = iter(self.coordinator.data)
event = next(schedule, None)
if not event: # Schedule is empty
return None
while (
not event[KEY_SKIPPABLE] or KEY_SKIP in event[KEY_RUN_SUMMARIES][0]
): # Not being skippable indicates the event is in the past
event = next(schedule, None)
if not event: # Schedule only has past or skipped events
return None
self._previous_event = event # Store for future use
return event
async def async_get_events(
self, hass: HomeAssistant, start_date: datetime, end_date: datetime
) -> list[CalendarEvent]:
"""Get all events in a specific time frame."""
if not self.coordinator.data:
raise HomeAssistantError("No events scheduled")
schedule = self.coordinator.data
event_list: list[CalendarEvent] = []
for run in schedule:
event_start = dt_util.as_local(
dt_util.parse_datetime(run[KEY_START_TIME], raise_on_error=True)
)
if event_start > end_date:
break
if run[KEY_SKIPPABLE]: # Future events
event_end = event_start + timedelta(
seconds=int(run[KEY_TOTAL_RUN_DURATION])
)
else: # Past events
event_end = event_start + timedelta(
seconds=int(run[KEY_RUN_SUMMARIES][0][KEY_DURATION_SECONDS])
)
if (
event_end > start_date
and event_start < end_date
and KEY_SKIP not in run[KEY_RUN_SUMMARIES][0]
):
valves = ", ".join(
[event[KEY_VALVE_NAME] for event in run[KEY_RUN_SUMMARIES]]
)
event = CalendarEvent(
summary=run[KEY_PROGRAM_NAME],
start=event_start,
end=event_end,
description=valves,
location=self._location,
uid=f"{run[KEY_PROGRAM_ID]}/{run[KEY_START_TIME]}",
)
event_list.append(event)
return event_list
async def async_delete_event(
self,
uid: str,
recurrence_id: str | None = None,
recurrence_range: str | None = None,
) -> None:
"""Skip an upcoming event on the calendar."""
program, timestamp = uid.split("/")
await self.hass.async_add_executor_job(
self.base_station.create_skip, program, timestamp
)
await self.coordinator.async_refresh()

View File

@ -51,6 +51,7 @@ KEY_CUSTOM_SLOPE = "customSlope"
# Smart Hose timer
KEY_BASE_STATIONS = "baseStations"
KEY_VALVES = "valves"
KEY_VALVE_NAME = "valveName"
KEY_REPORTED_STATE = "reportedState"
KEY_STATE = "state"
KEY_CONNECTED = "connected"
@ -64,6 +65,16 @@ KEY_DEFAULT_RUNTIME = "defaultRuntimeSeconds"
KEY_DURATION_SECONDS = "durationSeconds"
KEY_FLOW_DETECTED = "flowDetected"
KEY_START_TIME = "start"
KEY_DAY_VIEWS = "valveDayViews"
KEY_RUN_SUMMARIES = "valveRunSummaries"
KEY_PROGRAM_ID = "programId"
KEY_PROGRAM_NAME = "programName"
KEY_PROGRAM_RUN_SUMMARIES = "valveProgramRunSummaries"
KEY_TOTAL_RUN_DURATION = "totalRunDurationSeconds"
KEY_ADDRESS = "address"
KEY_LOCALITY = "locality"
KEY_SKIP = "skip"
KEY_SKIPPABLE = "skippable"
STATUS_ONLINE = "ONLINE"

View File

@ -1,7 +1,8 @@
"""Coordinator object for the Rachio integration."""
from datetime import timedelta
from datetime import datetime, timedelta
import logging
from operator import itemgetter
from typing import Any
from rachiopy import Rachio
@ -10,11 +11,23 @@ from requests.exceptions import Timeout
from homeassistant.core import HomeAssistant
from homeassistant.helpers.debounce import Debouncer
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util import dt as dt_util
from .const import DOMAIN, KEY_ID, KEY_VALVES
from .const import (
DOMAIN,
KEY_DAY_VIEWS,
KEY_ID,
KEY_PROGRAM_RUN_SUMMARIES,
KEY_START_TIME,
KEY_VALVES,
)
_LOGGER = logging.getLogger(__name__)
DAY = "day"
MONTH = "month"
YEAR = "year"
UPDATE_DELAY_TIME = 8
@ -54,3 +67,55 @@ class RachioUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
except Timeout as err:
raise UpdateFailed(f"Could not connect to the Rachio API: {err}") from err
return {valve[KEY_ID]: valve for valve in data[1][KEY_VALVES]}
class RachioScheduleUpdateCoordinator(DataUpdateCoordinator[list[dict[str, Any]]]):
"""Coordinator for fetching hose timer schedules."""
def __init__(
self,
hass: HomeAssistant,
rachio: Rachio,
base_station,
) -> None:
"""Initialize a Rachio schedule coordinator."""
self.hass = hass
self.rachio = rachio
self.base_station = base_station
super().__init__(
hass,
_LOGGER,
name=f"{DOMAIN} schedule update coordinator",
update_interval=timedelta(minutes=30),
)
async def _async_update_data(self) -> list[dict[str, Any]]:
"""Retrieve data for the past week and the next 60 days."""
_now: datetime = dt_util.now()
_time_start = _now - timedelta(days=7)
_time_end = _now + timedelta(days=60)
start: dict[str, int] = {
YEAR: _time_start.year,
MONTH: _time_start.month,
DAY: _time_start.day,
}
end: dict[str, int] = {
YEAR: _time_end.year,
MONTH: _time_end.month,
DAY: _time_end.day,
}
try:
schedule = await self.hass.async_add_executor_job(
self.rachio.summary.get_valve_day_views,
self.base_station[KEY_ID],
start,
end,
)
except Timeout as err:
raise UpdateFailed(f"Could not connect to the Rachio API: {err}") from err
events = []
# Flatten and sort dates
for event in schedule[1][KEY_DAY_VIEWS]:
events.extend(event[KEY_PROGRAM_RUN_SUMMARIES])
return sorted(events, key=itemgetter(KEY_START_TIME))

View File

@ -38,7 +38,7 @@ from .const import (
SERVICE_STOP_WATERING,
WEBHOOK_CONST_ID,
)
from .coordinator import RachioUpdateCoordinator
from .coordinator import RachioScheduleUpdateCoordinator, RachioUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
@ -187,7 +187,10 @@ class RachioPerson:
base_count = len(base_stations)
self._base_stations.extend(
RachioBaseStation(
rachio, base, RachioUpdateCoordinator(hass, rachio, base, base_count)
rachio,
base,
RachioUpdateCoordinator(hass, rachio, base, base_count),
RachioScheduleUpdateCoordinator(hass, rachio, base),
)
for base in base_stations
)
@ -348,12 +351,17 @@ class RachioBaseStation:
"""Represent a smart hose timer base station."""
def __init__(
self, rachio: Rachio, data: dict[str, Any], coordinator: RachioUpdateCoordinator
self,
rachio: Rachio,
data: dict[str, Any],
status_coordinator: RachioUpdateCoordinator,
schedule_coordinator: RachioScheduleUpdateCoordinator,
) -> None:
"""Initialize a smart hose timer base station."""
self.rachio = rachio
self._id = data[KEY_ID]
self.coordinator = coordinator
self.status_coordinator = status_coordinator
self.schedule_coordinator = schedule_coordinator
def start_watering(self, valve_id: str, duration: int) -> None:
"""Start watering on this valve."""
@ -363,6 +371,10 @@ class RachioBaseStation:
"""Stop watering on this valve."""
self.rachio.valve.stop_watering(valve_id)
def create_skip(self, program_id: str, timestamp: str) -> None:
"""Create a skip for a scheduled event."""
self.rachio.program.create_skip_overrides(program_id, timestamp)
def is_invalid_auth_code(http_status_code: int) -> bool:
"""HTTP status codes that mean invalid auth."""

View File

@ -33,6 +33,11 @@
"name": "Rain"
}
},
"calendar": {
"calendar": {
"name": "Rachio Base Station {base}"
}
},
"switch": {
"standby": {
"name": "Standby"

View File

@ -195,9 +195,9 @@ def _create_entities(hass: HomeAssistant, config_entry: ConfigEntry) -> list[Ent
for schedule in schedules + flex_schedules
)
entities.extend(
RachioValve(person, base_station, valve, base_station.coordinator)
RachioValve(person, base_station, valve, base_station.status_coordinator)
for base_station in person.base_stations
for valve in base_station.coordinator.data.values()
for valve in base_station.status_coordinator.data.values()
)
return entities