Add Rachio smart hose timer support (#107901)

Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
Brian Rogers 2024-03-23 13:36:03 -04:00 committed by GitHub
parent 3c13a28357
commit dbb4cf0ee7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 263 additions and 17 deletions

View file

@ -1062,6 +1062,7 @@ omit =
homeassistant/components/rabbitair/fan.py
homeassistant/components/rachio/__init__.py
homeassistant/components/rachio/binary_sensor.py
homeassistant/components/rachio/coordinator.py
homeassistant/components/rachio/device.py
homeassistant/components/rachio/entity.py
homeassistant/components/rachio/switch.py

View file

@ -83,7 +83,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
raise ConfigEntryNotReady from error
# Check for Rachio controller devices
if not person.controllers:
if not person.controllers and not person.base_stations:
_LOGGER.error("No Rachio devices found in account %s", person.username)
return False
_LOGGER.info(
@ -91,10 +91,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"%d Rachio device(s) found; The url %s must be accessible from the internet"
" in order to receive updates"
),
len(person.controllers),
len(person.controllers) + len(person.base_stations),
webhook_url,
)
for base in person.base_stations:
await base.coordinator.async_config_entry_first_refresh()
# Enable platform
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = person
async_register_webhook(hass, entry)

View file

@ -26,6 +26,7 @@ KEY_NAME = "name"
KEY_MODEL = "model"
KEY_ON = "on"
KEY_DURATION = "totalDuration"
KEY_DURATION_MINUTES = "duration"
KEY_RAIN_DELAY = "rainDelayExpirationDate"
KEY_RAIN_DELAY_END = "endTime"
KEY_RAIN_SENSOR_TRIPPED = "rainSensorTripped"
@ -47,6 +48,21 @@ KEY_CUSTOM_SHADE = "customShade"
KEY_CUSTOM_CROP = "customCrop"
KEY_CUSTOM_SLOPE = "customSlope"
# Smart Hose timer
KEY_BASE_STATIONS = "baseStations"
KEY_VALVES = "valves"
KEY_REPORTED_STATE = "reportedState"
KEY_STATE = "state"
KEY_CONNECTED = "connected"
KEY_CURRENT_STATUS = "lastWateringAction"
KEY_DETECT_FLOW = "detectFlow"
KEY_BATTERY_STATUS = "batteryStatus"
KEY_REASON = "reason"
KEY_DEFAULT_RUNTIME = "defaultRuntimeSeconds"
KEY_DURATION_SECONDS = "durationSeconds"
KEY_FLOW_DETECTED = "flowDetected"
KEY_START_TIME = "start"
STATUS_ONLINE = "ONLINE"
MODEL_GENERATION_1 = "GENERATION1"
@ -56,6 +72,7 @@ SERVICE_PAUSE_WATERING = "pause_watering"
SERVICE_RESUME_WATERING = "resume_watering"
SERVICE_STOP_WATERING = "stop_watering"
SERVICE_SET_ZONE_MOISTURE = "set_zone_moisture_percent"
SERVICE_START_WATERING = "start_watering"
SERVICE_START_MULTIPLE_ZONES = "start_multiple_zone_schedule"
SIGNAL_RACHIO_UPDATE = f"{DOMAIN}_update"

View file

@ -0,0 +1,56 @@
"""Coordinator object for the Rachio integration."""
from datetime import timedelta
import logging
from typing import Any
from rachiopy import Rachio
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 .const import DOMAIN, KEY_ID, KEY_VALVES
_LOGGER = logging.getLogger(__name__)
UPDATE_DELAY_TIME = 8
class RachioUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"""Coordinator Class for Rachio Hose Timers."""
def __init__(
self,
hass: HomeAssistant,
rachio: Rachio,
base_station,
base_count: int,
) -> None:
"""Initialize the Rachio Update Coordinator."""
self.hass = hass
self.rachio = rachio
self.base_station = base_station
super().__init__(
hass,
_LOGGER,
name=f"{DOMAIN} update coordinator",
# To avoid exceeding the rate limit, increase polling interval for
# each additional base station on the account
update_interval=timedelta(minutes=(base_count + 1)),
# Debouncer used because the API takes a bit to update state changes
request_refresh_debouncer=Debouncer(
hass, _LOGGER, cooldown=UPDATE_DELAY_TIME, immediate=False
),
)
async def _async_update_data(self) -> dict[str, Any]:
"""Update smart hose timer data."""
try:
data = await self.hass.async_add_executor_job(
self.rachio.valve.list_valves, self.base_station[KEY_ID]
)
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]}

View file

@ -17,6 +17,7 @@ from homeassistant.helpers import config_validation as cv
from .const import (
DOMAIN,
KEY_BASE_STATIONS,
KEY_DEVICES,
KEY_ENABLED,
KEY_EXTERNAL_ID,
@ -37,6 +38,7 @@ from .const import (
SERVICE_STOP_WATERING,
WEBHOOK_CONST_ID,
)
from .coordinator import RachioUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
@ -67,6 +69,7 @@ class RachioPerson:
self.username = None
self._id: str | None = None
self._controllers: list[RachioIro] = []
self._base_stations: list[RachioBaseStation] = []
async def async_setup(self, hass: HomeAssistant) -> None:
"""Create rachio devices and services."""
@ -78,30 +81,34 @@ class RachioPerson:
can_pause = True
break
all_devices = [rachio_iro.name for rachio_iro in self._controllers]
all_controllers = [rachio_iro.name for rachio_iro in self._controllers]
def pause_water(service: ServiceCall) -> None:
"""Service to pause watering on all or specific controllers."""
duration = service.data[ATTR_DURATION]
devices = service.data.get(ATTR_DEVICES, all_devices)
devices = service.data.get(ATTR_DEVICES, all_controllers)
for iro in self._controllers:
if iro.name in devices:
iro.pause_watering(duration)
def resume_water(service: ServiceCall) -> None:
"""Service to resume watering on all or specific controllers."""
devices = service.data.get(ATTR_DEVICES, all_devices)
devices = service.data.get(ATTR_DEVICES, all_controllers)
for iro in self._controllers:
if iro.name in devices:
iro.resume_watering()
def stop_water(service: ServiceCall) -> None:
"""Service to stop watering on all or specific controllers."""
devices = service.data.get(ATTR_DEVICES, all_devices)
devices = service.data.get(ATTR_DEVICES, all_controllers)
for iro in self._controllers:
if iro.name in devices:
iro.stop_watering()
# If only hose timers on account, none of these services apply
if not all_controllers:
return
hass.services.async_register(
DOMAIN,
SERVICE_STOP_WATERING,
@ -145,6 +152,9 @@ class RachioPerson:
raise ConfigEntryNotReady(f"API Error: {data}")
self.username = data[1][KEY_USERNAME]
devices: list[dict[str, Any]] = data[1][KEY_DEVICES]
base_station_data = rachio.valve.list_base_stations(self._id)
base_stations: list[dict[str, Any]] = base_station_data[1][KEY_BASE_STATIONS]
for controller in devices:
webhooks = rachio.notification.get_device_webhook(controller[KEY_ID])[1]
# The API does not provide a way to tell if a controller is shared
@ -174,6 +184,14 @@ class RachioPerson:
rachio_iro.setup()
self._controllers.append(rachio_iro)
base_count = len(base_stations)
self._base_stations.extend(
RachioBaseStation(
rachio, base, RachioUpdateCoordinator(hass, rachio, base, base_count)
)
for base in base_stations
)
_LOGGER.info('Using Rachio API as user "%s"', self.username)
@property
@ -186,6 +204,11 @@ class RachioPerson:
"""Get a list of controllers managed by this account."""
return self._controllers
@property
def base_stations(self) -> list[RachioBaseStation]:
"""List of smart hose timer base stations."""
return self._base_stations
def start_multiple_zones(self, zones) -> None:
"""Start multiple zones."""
self.rachio.zone.start_multiple(zones)
@ -321,6 +344,28 @@ class RachioIro:
_LOGGER.debug("Resuming watering on %s", self)
class RachioBaseStation:
"""Represent a smart hose timer base station."""
def __init__(
self, rachio: Rachio, data: dict[str, Any], coordinator: RachioUpdateCoordinator
) -> None:
"""Initialize a hose time base station."""
self.rachio = rachio
self._id = data[KEY_ID]
self.serial_number = data[KEY_SERIAL_NUMBER]
self.mac_address = data[KEY_MAC_ADDRESS]
self.coordinator = coordinator
def start_watering(self, valve_id: str, duration: int) -> None:
"""Start watering on this valve."""
self.rachio.valve.start_watering(valve_id, duration)
def stop_watering(self, valve_id: str) -> None:
"""Stop watering on this valve."""
self.rachio.valve.stop_watering(valve_id)
def is_invalid_auth_code(http_status_code: int) -> bool:
"""HTTP status codes that mean invalid auth."""
return http_status_code in (HTTPStatus.UNAUTHORIZED, HTTPStatus.FORBIDDEN)

View file

@ -14,6 +14,7 @@
"start_multiple_zone_schedule": "mdi:play",
"pause_watering": "mdi:pause",
"resume_watering": "mdi:play",
"stop_watering": "mdi:stop"
"stop_watering": "mdi:stop",
"start_watering": "mdi:water"
}
}

View file

@ -11,6 +11,17 @@ set_zone_moisture_percent:
min: 0
max: 100
unit_of_measurement: "%"
start_watering:
target:
entity:
integration: rachio
domain: switch
fields:
duration:
example: 15
required: false
selector:
object:
start_multiple_zone_schedule:
target:
entity:

View file

@ -63,6 +63,16 @@
}
}
},
"start_watering": {
"name": "Start watering",
"description": "Start a single zone, a schedule or any number of smart hose timers.",
"fields": {
"duration": {
"name": "Duration",
"description": "Number of minutes to run. For sprinkler zones the maximum duration is 3 hours, or 24 hours for smart hose timers. Leave empty for schedules."
}
}
},
"pause_watering": {
"name": "Pause watering",
"description": "Pause any currently running zones or schedules.",

View file

@ -11,19 +11,28 @@ import voluptuous as vol
from homeassistant.components.switch import SwitchEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_ENTITY_ID, ATTR_ID
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv, entity_platform
from homeassistant.helpers import (
config_validation as cv,
device_registry as dr,
entity_platform,
)
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.event import async_track_point_in_utc_time
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util.dt import as_timestamp, now, parse_datetime, utc_from_timestamp
from .const import (
CONF_MANUAL_RUN_MINS,
DEFAULT_MANUAL_RUN_MINS,
DEFAULT_NAME,
DOMAIN as DOMAIN_RACHIO,
KEY_CONNECTED,
KEY_CURRENT_STATUS,
KEY_CUSTOM_CROP,
KEY_CUSTOM_SHADE,
KEY_CUSTOM_SLOPE,
@ -36,7 +45,9 @@ from .const import (
KEY_ON,
KEY_RAIN_DELAY,
KEY_RAIN_DELAY_END,
KEY_REPORTED_STATE,
KEY_SCHEDULE_ID,
KEY_STATE,
KEY_SUBTYPE,
KEY_SUMMARY,
KEY_TYPE,
@ -46,6 +57,7 @@ from .const import (
SCHEDULE_TYPE_FLEX,
SERVICE_SET_ZONE_MOISTURE,
SERVICE_START_MULTIPLE_ZONES,
SERVICE_START_WATERING,
SIGNAL_RACHIO_CONTROLLER_UPDATE,
SIGNAL_RACHIO_RAIN_DELAY_UPDATE,
SIGNAL_RACHIO_SCHEDULE_UPDATE,
@ -55,6 +67,7 @@ from .const import (
SLOPE_SLIGHT,
SLOPE_STEEP,
)
from .coordinator import RachioUpdateCoordinator
from .device import RachioPerson
from .entity import RachioDevice
from .webhooks import (
@ -80,6 +93,7 @@ ATTR_SCHEDULE_ENABLED = "Enabled"
ATTR_SCHEDULE_DURATION = "Duration"
ATTR_SCHEDULE_TYPE = "Type"
ATTR_SORT_ORDER = "sortOrder"
ATTR_WATERING_DURATION = "Watering Duration seconds"
ATTR_ZONE_NUMBER = "Zone number"
ATTR_ZONE_SHADE = "Shade"
ATTR_ZONE_SLOPE = "Slope"
@ -141,6 +155,19 @@ async def async_setup_entry(
else:
raise HomeAssistantError("No matching zones found in given entity_ids")
platform = entity_platform.async_get_current_platform()
platform.async_register_entity_service(
SERVICE_START_WATERING,
{
vol.Optional(ATTR_DURATION): cv.positive_int,
},
"turn_on",
)
# If only hose timers on account, none of these services apply
if not zone_entities:
return
hass.services.async_register(
DOMAIN_RACHIO,
SERVICE_START_MULTIPLE_ZONES,
@ -176,6 +203,11 @@ def _create_entities(hass: HomeAssistant, config_entry: ConfigEntry) -> list[Ent
RachioSchedule(person, controller, schedule, current_schedule)
for schedule in schedules + flex_schedules
)
entities.extend(
RachioValve(person, base_station, valve, base_station.coordinator)
for base_station in person.base_stations
for valve in base_station.coordinator.data.values()
)
return entities
@ -246,9 +278,9 @@ class RachioRainDelay(RachioSwitch):
_attr_has_entity_name = True
_attr_translation_key = "rain_delay"
def __init__(self, controller):
def __init__(self, controller) -> None:
"""Set up a Rachio rain delay switch."""
self._cancel_update = None
self._cancel_update: CALLBACK_TYPE | None = None
super().__init__(controller)
@property
@ -324,7 +356,7 @@ class RachioZone(RachioSwitch):
_attr_icon = "mdi:water"
def __init__(self, person, controller, data, current_schedule):
def __init__(self, person, controller, data, current_schedule) -> None:
"""Initialize a new Rachio Zone."""
self.id = data[KEY_ID]
self._attr_name = data[KEY_NAME]
@ -379,6 +411,9 @@ class RachioZone(RachioSwitch):
self.turn_off()
# Start this zone
if ATTR_DURATION in kwargs:
manual_run_time = timedelta(minutes=kwargs[ATTR_DURATION])
else:
manual_run_time = timedelta(
minutes=self._person.config_entry.options.get(
CONF_MANUAL_RUN_MINS, DEFAULT_MANUAL_RUN_MINS
@ -435,7 +470,7 @@ class RachioZone(RachioSwitch):
class RachioSchedule(RachioSwitch):
"""Representation of one fixed schedule on the Rachio Iro."""
def __init__(self, person, controller, data, current_schedule):
def __init__(self, person, controller, data, current_schedule) -> None:
"""Initialize a new Rachio Schedule."""
self._schedule_id = data[KEY_ID]
self._duration = data[KEY_DURATION]
@ -509,3 +544,70 @@ class RachioSchedule(RachioSwitch):
self.hass, SIGNAL_RACHIO_SCHEDULE_UPDATE, self._async_handle_update
)
)
class RachioValve(CoordinatorEntity[RachioUpdateCoordinator], SwitchEntity):
"""Representation of one smart hose timer valve."""
def __init__(
self, person, base, data, coordinator: RachioUpdateCoordinator
) -> None:
"""Initialize a new smart hose valve."""
super().__init__(coordinator)
self._person = person
self._base = base
self.id = data[KEY_ID]
self._attr_name = data[KEY_NAME]
self._attr_unique_id = f"{self.id}-valve"
self._static_attrs = data[KEY_STATE][KEY_REPORTED_STATE]
self._attr_is_on = KEY_CURRENT_STATUS in self._static_attrs
self._attr_device_info = DeviceInfo(
identifiers={
(
DOMAIN_RACHIO,
self.id,
)
},
connections={(dr.CONNECTION_NETWORK_MAC, self._base.mac_address)},
manufacturer=DEFAULT_NAME,
model="Smart Hose Timer",
name=self._attr_name,
configuration_url="https://app.rach.io",
)
@property
def available(self) -> bool:
"""Return if the valve is available."""
return super().available and self._static_attrs[KEY_CONNECTED]
def turn_on(self, **kwargs: Any) -> None:
"""Turn on this valve."""
if ATTR_DURATION in kwargs:
manual_run_time = timedelta(minutes=kwargs[ATTR_DURATION])
else:
manual_run_time = timedelta(
minutes=self._person.config_entry.options.get(
CONF_MANUAL_RUN_MINS, DEFAULT_MANUAL_RUN_MINS
)
)
self._base.start_watering(self.id, manual_run_time.seconds)
self._attr_is_on = True
self.schedule_update_ha_state(force_refresh=True)
_LOGGER.debug("Starting valve %s for %s", self.name, str(manual_run_time))
def turn_off(self, **kwargs: Any) -> None:
"""Turn off this valve."""
self._base.stop_watering(self.id)
self._attr_is_on = False
self.schedule_update_ha_state(force_refresh=True)
_LOGGER.debug("Stopping watering on valve %s", self.name)
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated coordinator data."""
data = self.coordinator.data[self.id]
self._static_attrs = data[KEY_STATE][KEY_REPORTED_STATE]
self._attr_is_on = KEY_CURRENT_STATUS in self._static_attrs
super()._handle_coordinator_update()