diff --git a/homeassistant/components/rachio/__init__.py b/homeassistant/components/rachio/__init__.py index f91a7b4fa75..a5922e0cb95 100644 --- a/homeassistant/components/rachio/__init__.py +++ b/homeassistant/components/rachio/__init__.py @@ -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 diff --git a/homeassistant/components/rachio/binary_sensor.py b/homeassistant/components/rachio/binary_sensor.py index 5a8b5856db7..189a08e998d 100644 --- a/homeassistant/components/rachio/binary_sensor.py +++ b/homeassistant/components/rachio/binary_sensor.py @@ -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 diff --git a/homeassistant/components/rachio/calendar.py b/homeassistant/components/rachio/calendar.py new file mode 100644 index 00000000000..5c7e13c748a --- /dev/null +++ b/homeassistant/components/rachio/calendar.py @@ -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() diff --git a/homeassistant/components/rachio/const.py b/homeassistant/components/rachio/const.py index 891e92f55a1..ad670fc3608 100644 --- a/homeassistant/components/rachio/const.py +++ b/homeassistant/components/rachio/const.py @@ -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" diff --git a/homeassistant/components/rachio/coordinator.py b/homeassistant/components/rachio/coordinator.py index 4f8cc87daef..25c40bd6656 100644 --- a/homeassistant/components/rachio/coordinator.py +++ b/homeassistant/components/rachio/coordinator.py @@ -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)) diff --git a/homeassistant/components/rachio/device.py b/homeassistant/components/rachio/device.py index 09f7eaf1b06..0bbb862753e 100644 --- a/homeassistant/components/rachio/device.py +++ b/homeassistant/components/rachio/device.py @@ -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.""" diff --git a/homeassistant/components/rachio/strings.json b/homeassistant/components/rachio/strings.json index 2e4de262d21..ad7a277d23a 100644 --- a/homeassistant/components/rachio/strings.json +++ b/homeassistant/components/rachio/strings.json @@ -33,6 +33,11 @@ "name": "Rain" } }, + "calendar": { + "calendar": { + "name": "Rachio Base Station {base}" + } + }, "switch": { "standby": { "name": "Standby" diff --git a/homeassistant/components/rachio/switch.py b/homeassistant/components/rachio/switch.py index 8a35225b9b2..92e7c0ea2ba 100644 --- a/homeassistant/components/rachio/switch.py +++ b/homeassistant/components/rachio/switch.py @@ -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