mirror of
https://github.com/home-assistant/core.git
synced 2025-07-19 11:17:21 +00:00
Add calendar for Rachio smart hose timer (#120030)
This commit is contained in:
parent
0f079454bb
commit
63b0feeae7
@ -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
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
177
homeassistant/components/rachio/calendar.py
Normal file
177
homeassistant/components/rachio/calendar.py
Normal 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()
|
@ -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"
|
||||||
|
|
||||||
|
@ -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))
|
||||||
|
@ -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."""
|
||||||
|
@ -33,6 +33,11 @@
|
|||||||
"name": "Rain"
|
"name": "Rain"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"calendar": {
|
||||||
|
"calendar": {
|
||||||
|
"name": "Rachio Base Station {base}"
|
||||||
|
}
|
||||||
|
},
|
||||||
"switch": {
|
"switch": {
|
||||||
"standby": {
|
"standby": {
|
||||||
"name": "Standby"
|
"name": "Standby"
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user