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__) _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) 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: 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 # Enable platform
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = person 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(RachioControllerOnlineBinarySensor(controller))
entities.append(RachioRainSensor(controller)) entities.append(RachioRainSensor(controller))
entities.extend( entities.extend(
RachioHoseTimerBattery(valve, base_station.coordinator) RachioHoseTimerBattery(valve, base_station.status_coordinator)
for base_station in person.base_stations 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 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 # Smart Hose timer
KEY_BASE_STATIONS = "baseStations" KEY_BASE_STATIONS = "baseStations"
KEY_VALVES = "valves" KEY_VALVES = "valves"
KEY_VALVE_NAME = "valveName"
KEY_REPORTED_STATE = "reportedState" KEY_REPORTED_STATE = "reportedState"
KEY_STATE = "state" KEY_STATE = "state"
KEY_CONNECTED = "connected" KEY_CONNECTED = "connected"
@ -64,6 +65,16 @@ KEY_DEFAULT_RUNTIME = "defaultRuntimeSeconds"
KEY_DURATION_SECONDS = "durationSeconds" KEY_DURATION_SECONDS = "durationSeconds"
KEY_FLOW_DETECTED = "flowDetected" KEY_FLOW_DETECTED = "flowDetected"
KEY_START_TIME = "start" 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" STATUS_ONLINE = "ONLINE"

View File

@ -1,7 +1,8 @@
"""Coordinator object for the Rachio integration.""" """Coordinator object for the Rachio integration."""
from datetime import timedelta from datetime import datetime, timedelta
import logging import logging
from operator import itemgetter
from typing import Any from typing import Any
from rachiopy import Rachio from rachiopy import Rachio
@ -10,11 +11,23 @@ from requests.exceptions import Timeout
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.debounce import Debouncer
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed 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__) _LOGGER = logging.getLogger(__name__)
DAY = "day"
MONTH = "month"
YEAR = "year"
UPDATE_DELAY_TIME = 8 UPDATE_DELAY_TIME = 8
@ -54,3 +67,55 @@ class RachioUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
except Timeout as err: except Timeout as err:
raise UpdateFailed(f"Could not connect to the Rachio API: {err}") from 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]} 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, SERVICE_STOP_WATERING,
WEBHOOK_CONST_ID, WEBHOOK_CONST_ID,
) )
from .coordinator import RachioUpdateCoordinator from .coordinator import RachioScheduleUpdateCoordinator, RachioUpdateCoordinator
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -187,7 +187,10 @@ class RachioPerson:
base_count = len(base_stations) base_count = len(base_stations)
self._base_stations.extend( self._base_stations.extend(
RachioBaseStation( 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 for base in base_stations
) )
@ -348,12 +351,17 @@ class RachioBaseStation:
"""Represent a smart hose timer base station.""" """Represent a smart hose timer base station."""
def __init__( 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: ) -> None:
"""Initialize a smart hose timer base station.""" """Initialize a smart hose timer base station."""
self.rachio = rachio self.rachio = rachio
self._id = data[KEY_ID] 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: def start_watering(self, valve_id: str, duration: int) -> None:
"""Start watering on this valve.""" """Start watering on this valve."""
@ -363,6 +371,10 @@ class RachioBaseStation:
"""Stop watering on this valve.""" """Stop watering on this valve."""
self.rachio.valve.stop_watering(valve_id) 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: def is_invalid_auth_code(http_status_code: int) -> bool:
"""HTTP status codes that mean invalid auth.""" """HTTP status codes that mean invalid auth."""

View File

@ -33,6 +33,11 @@
"name": "Rain" "name": "Rain"
} }
}, },
"calendar": {
"calendar": {
"name": "Rachio Base Station {base}"
}
},
"switch": { "switch": {
"standby": { "standby": {
"name": "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 for schedule in schedules + flex_schedules
) )
entities.extend( 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 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 return entities