"""Integration with the Rachio Iro sprinkler system controller.""" from abc import abstractmethod from contextlib import suppress from datetime import timedelta import logging from typing import Any, override import voluptuous as vol from homeassistant.components.switch import SwitchEntity from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.util.dt import as_timestamp, now, parse_datetime, utc_from_timestamp from .const import ( CONF_MANUAL_RUN_MINS, DEFAULT_MANUAL_RUN_MINS, KEY_CURRENT_STATUS, KEY_CUSTOM_CROP, KEY_CUSTOM_SHADE, KEY_CUSTOM_SLOPE, KEY_DEVICE_ID, KEY_DURATION, KEY_ENABLED, KEY_ID, KEY_IMAGE_URL, KEY_NAME, KEY_ON, KEY_RAIN_DELAY, KEY_RAIN_DELAY_END, KEY_SCHEDULE_ID, KEY_SUBTYPE, KEY_SUMMARY, KEY_TYPE, KEY_ZONE_ID, KEY_ZONE_NUMBER, SCHEDULE_TYPE_FIXED, SCHEDULE_TYPE_FLEX, SERVICE_SET_ZONE_MOISTURE, SERVICE_START_WATERING, SIGNAL_RACHIO_CONTROLLER_UPDATE, SIGNAL_RACHIO_RAIN_DELAY_UPDATE, SIGNAL_RACHIO_SCHEDULE_UPDATE, SIGNAL_RACHIO_ZONE_UPDATE, SLOPE_FLAT, SLOPE_MODERATE, SLOPE_SLIGHT, SLOPE_STEEP, ) from .device import RachioConfigEntry from .entity import RachioDevice, RachioHoseTimerEntity from .webhooks import ( SUBTYPE_RAIN_DELAY_OFF, SUBTYPE_RAIN_DELAY_ON, SUBTYPE_SCHEDULE_COMPLETED, SUBTYPE_SCHEDULE_STARTED, SUBTYPE_SCHEDULE_STOPPED, SUBTYPE_SLEEP_MODE_OFF, SUBTYPE_SLEEP_MODE_ON, SUBTYPE_ZONE_COMPLETED, SUBTYPE_ZONE_PAUSED, SUBTYPE_ZONE_STARTED, SUBTYPE_ZONE_STOPPED, ) _LOGGER = logging.getLogger(__name__) ATTR_DURATION = "duration" ATTR_PERCENT = "percent" ATTR_SCHEDULE_SUMMARY = "Summary" ATTR_SCHEDULE_ENABLED = "Enabled" ATTR_SCHEDULE_DURATION = "Duration" ATTR_SCHEDULE_TYPE = "Type" ATTR_WATERING_DURATION = "Watering Duration seconds" ATTR_ZONE_NUMBER = "Zone number" ATTR_ZONE_SHADE = "Shade" ATTR_ZONE_SLOPE = "Slope" ATTR_ZONE_SUMMARY = "Summary" ATTR_ZONE_TYPE = "Type" async def async_setup_entry( hass: HomeAssistant, config_entry: RachioConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Rachio switches.""" has_flex_sched = False entities = await hass.async_add_executor_job(_create_entities, hass, config_entry) for entity in entities: if isinstance(entity, RachioSchedule) and entity.type == SCHEDULE_TYPE_FLEX: has_flex_sched = True async_add_entities(entities) 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 has_flex_sched: platform = entity_platform.async_get_current_platform() platform.async_register_entity_service( SERVICE_SET_ZONE_MOISTURE, {vol.Required(ATTR_PERCENT): cv.positive_int}, "set_moisture_percent", ) def _create_entities( hass: HomeAssistant, config_entry: RachioConfigEntry ) -> list[Entity]: entities: list[Entity] = [] person = config_entry.runtime_data # Fetch the schedule once at startup # in order to avoid every zone doing it for controller in person.controllers: entities.append(RachioStandbySwitch(controller)) entities.append(RachioRainDelay(controller)) zones = controller.list_zones() schedules = controller.list_schedules() flex_schedules = controller.list_flex_schedules() current_schedule = controller.current_schedule entities.extend( RachioZone(person, controller, zone, current_schedule) for zone in zones ) entities.extend( RachioSchedule(person, controller, schedule, current_schedule) for schedule in schedules + flex_schedules ) entities.extend( RachioValve(person, base_station, valve, base_station.status_coordinator) for base_station in person.base_stations for valve in base_station.status_coordinator.data.values() ) return entities class RachioSwitch(RachioDevice, SwitchEntity): """Represent a Rachio state that can be toggled.""" @callback def _async_handle_any_update(self, *args, **kwargs) -> None: """Determine whether an update event applies to this device.""" if args[0][KEY_DEVICE_ID] != self._controller.controller_id: # For another device return # For this device self._async_handle_update(args, kwargs) @abstractmethod def _async_handle_update(self, *args, **kwargs) -> None: """Handle incoming webhook data.""" class RachioStandbySwitch(RachioSwitch): """Representation of a standby status/button.""" _attr_has_entity_name = True _attr_translation_key = "standby" @property @override def unique_id(self) -> str: """Return a unique id by combining controller id and purpose.""" return f"{self._controller.controller_id}-standby" @callback @override def _async_handle_update(self, *args, **kwargs) -> None: """Update the state using webhook data.""" if args[0][0][KEY_SUBTYPE] == SUBTYPE_SLEEP_MODE_ON: self._attr_is_on = True elif args[0][0][KEY_SUBTYPE] == SUBTYPE_SLEEP_MODE_OFF: self._attr_is_on = False self.async_write_ha_state() @override def turn_on(self, **kwargs: Any) -> None: """Put the controller in standby mode.""" self._controller.rachio.device.turn_off(self._controller.controller_id) @override def turn_off(self, **kwargs: Any) -> None: """Resume controller functionality.""" self._controller.rachio.device.turn_on(self._controller.controller_id) @override async def async_added_to_hass(self) -> None: """Subscribe to updates.""" if KEY_ON in self._controller.init_data: self._attr_is_on = not self._controller.init_data[KEY_ON] self.async_on_remove( async_dispatcher_connect( self.hass, SIGNAL_RACHIO_CONTROLLER_UPDATE, self._async_handle_any_update, ) ) class RachioRainDelay(RachioSwitch): """Representation of a rain delay status/switch.""" _attr_has_entity_name = True _attr_translation_key = "rain_delay" def __init__(self, controller) -> None: """Set up a Rachio rain delay switch.""" self._cancel_update: CALLBACK_TYPE | None = None super().__init__(controller) @property @override def unique_id(self) -> str: """Return a unique id by combining controller id and purpose.""" return f"{self._controller.controller_id}-delay" @callback @override def _async_handle_update(self, *args, **kwargs) -> None: """Update the state using webhook data.""" if self._cancel_update: self._cancel_update() self._cancel_update = None if args[0][0][KEY_SUBTYPE] == SUBTYPE_RAIN_DELAY_ON: endtime = parse_datetime(args[0][0][KEY_RAIN_DELAY_END]) _LOGGER.debug("Rain delay expires at %s", endtime) self._attr_is_on = True assert endtime is not None self._cancel_update = async_track_point_in_utc_time( self.hass, self._delay_expiration, endtime ) elif args[0][0][KEY_SUBTYPE] == SUBTYPE_RAIN_DELAY_OFF: self._attr_is_on = False self.async_write_ha_state() @callback def _delay_expiration(self, *args) -> None: """Trigger when a rain delay expires.""" self._attr_is_on = False self._cancel_update = None self.async_write_ha_state() @override def turn_on(self, **kwargs: Any) -> None: """Activate a 24 hour rain delay on the controller.""" self._controller.rachio.device.rain_delay(self._controller.controller_id, 86400) _LOGGER.debug("Starting rain delay for 24 hours") @override def turn_off(self, **kwargs: Any) -> None: """Resume controller functionality.""" self._controller.rachio.device.rain_delay(self._controller.controller_id, 0) _LOGGER.debug("Canceling rain delay") @override async def async_added_to_hass(self) -> None: """Subscribe to updates.""" if KEY_RAIN_DELAY in self._controller.init_data: self._attr_is_on = self._controller.init_data[ KEY_RAIN_DELAY ] / 1000 > as_timestamp(now()) # If the controller was in a rain delay state during # a reboot, this re-sets the timer if self._attr_is_on is True: delay_end = utc_from_timestamp( self._controller.init_data[KEY_RAIN_DELAY] / 1000 ) _LOGGER.debug("Re-setting rain delay timer for %s", delay_end) self._cancel_update = async_track_point_in_utc_time( self.hass, self._delay_expiration, delay_end ) self.async_on_remove( async_dispatcher_connect( self.hass, SIGNAL_RACHIO_RAIN_DELAY_UPDATE, self._async_handle_any_update, ) ) class RachioZone(RachioSwitch): """Representation of one zone of sprinklers connected to the Rachio Iro.""" _attr_icon = "mdi:water" 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] self._zone_number = data[KEY_ZONE_NUMBER] self._zone_enabled = data[KEY_ENABLED] self._attr_entity_picture = data.get(KEY_IMAGE_URL) self._person = person self._shade_type = data.get(KEY_CUSTOM_SHADE, {}).get(KEY_NAME) self._zone_type = data.get(KEY_CUSTOM_CROP, {}).get(KEY_NAME) self._slope_type = data.get(KEY_CUSTOM_SLOPE, {}).get(KEY_NAME) self._summary = "" self._current_schedule = current_schedule self._attr_unique_id = f"{controller.controller_id}-zone-{self.id}" super().__init__(controller) @override def __str__(self): """Display the zone as a string.""" return f'Rachio Zone "{self.name}" on {self._controller!s}' @property def zone_id(self) -> str: """How the Rachio API refers to the zone.""" return self.id @property def zone_is_enabled(self) -> bool: """Return whether the zone is allowed to run.""" return self._zone_enabled @property @override def extra_state_attributes(self) -> dict[str, Any]: """Return the optional state attributes.""" props = {ATTR_ZONE_NUMBER: self._zone_number, ATTR_ZONE_SUMMARY: self._summary} if self._shade_type: props[ATTR_ZONE_SHADE] = self._shade_type if self._zone_type: props[ATTR_ZONE_TYPE] = self._zone_type if self._slope_type: if self._slope_type == SLOPE_FLAT: props[ATTR_ZONE_SLOPE] = "Flat" elif self._slope_type == SLOPE_SLIGHT: props[ATTR_ZONE_SLOPE] = "Slight" elif self._slope_type == SLOPE_MODERATE: props[ATTR_ZONE_SLOPE] = "Moderate" elif self._slope_type == SLOPE_STEEP: props[ATTR_ZONE_SLOPE] = "Steep" return props @override def turn_on(self, **kwargs: Any) -> None: """Start watering this zone.""" # Stop other zones first 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 ) ) # The API limit is 3 hours, and requires an int be passed self._controller.rachio.zone.start(self.zone_id, manual_run_time.seconds) _LOGGER.debug( "Watering %s on %s for %s", self.name, self._controller.name, str(manual_run_time), ) @override def turn_off(self, **kwargs: Any) -> None: """Stop watering all zones.""" self._controller.stop_watering() def set_moisture_percent(self, percent) -> None: """Set the zone moisture percent.""" _LOGGER.debug("Setting %s moisture to %s percent", self.name, percent) self._controller.rachio.zone.set_moisture_percent(self.id, percent / 100) @callback @override def _async_handle_update(self, *args, **kwargs) -> None: """Handle incoming webhook zone data.""" if args[0][KEY_ZONE_ID] != self.zone_id: return self._summary = args[0][KEY_SUMMARY] if args[0][KEY_SUBTYPE] == SUBTYPE_ZONE_STARTED: self._attr_is_on = True elif args[0][KEY_SUBTYPE] in [ SUBTYPE_ZONE_STOPPED, SUBTYPE_ZONE_COMPLETED, SUBTYPE_ZONE_PAUSED, ]: self._attr_is_on = False self.async_write_ha_state() @override async def async_added_to_hass(self) -> None: """Subscribe to updates.""" self._attr_is_on = self.zone_id == self._current_schedule.get(KEY_ZONE_ID) self.async_on_remove( async_dispatcher_connect( self.hass, SIGNAL_RACHIO_ZONE_UPDATE, self._async_handle_update ) ) class RachioSchedule(RachioSwitch): """Representation of one fixed schedule on the Rachio Iro.""" 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] self._schedule_enabled = data[KEY_ENABLED] self._summary = data[KEY_SUMMARY] self.type = data.get(KEY_TYPE, SCHEDULE_TYPE_FIXED) self._current_schedule = current_schedule self._attr_unique_id = ( f"{controller.controller_id}-schedule-{self._schedule_id}" ) self._attr_name = f"{data[KEY_NAME]} Schedule" super().__init__(controller) @property @override def icon(self) -> str: """Return the icon to display.""" return "mdi:water" if self.schedule_is_enabled else "mdi:water-off" @property @override def extra_state_attributes(self) -> dict[str, Any]: """Return the optional state attributes.""" return { ATTR_SCHEDULE_SUMMARY: self._summary, ATTR_SCHEDULE_ENABLED: self.schedule_is_enabled, ATTR_SCHEDULE_DURATION: f"{round(self._duration / 60)} minutes", ATTR_SCHEDULE_TYPE: self.type, } @property def schedule_is_enabled(self) -> bool: """Return whether the schedule is allowed to run.""" return self._schedule_enabled @override def turn_on(self, **kwargs: Any) -> None: """Start this schedule.""" self._controller.rachio.schedulerule.start(self._schedule_id) _LOGGER.debug( "Schedule %s started on %s", self.name, self._controller.name, ) @override def turn_off(self, **kwargs: Any) -> None: """Stop watering all zones.""" self._controller.stop_watering() @callback @override def _async_handle_update(self, *args, **kwargs) -> None: """Handle incoming webhook schedule data.""" # Schedule ID not passed when running individual zones, so we catch that error with suppress(KeyError): if args[0][KEY_SCHEDULE_ID] == self._schedule_id: if args[0][KEY_SUBTYPE] == SUBTYPE_SCHEDULE_STARTED: self._attr_is_on = True elif args[0][KEY_SUBTYPE] in [ SUBTYPE_SCHEDULE_STOPPED, SUBTYPE_SCHEDULE_COMPLETED, ]: self._attr_is_on = False self.async_write_ha_state() @override async def async_added_to_hass(self) -> None: """Subscribe to updates.""" self._attr_is_on = self._schedule_id == self._current_schedule.get( KEY_SCHEDULE_ID ) self.async_on_remove( async_dispatcher_connect( self.hass, SIGNAL_RACHIO_SCHEDULE_UPDATE, self._async_handle_update ) ) class RachioValve(RachioHoseTimerEntity, SwitchEntity): """Representation of one smart hose timer valve.""" _attr_name = None def __init__(self, person, base, data, coordinator) -> None: """Initialize a new smart hose valve.""" super().__init__(data, coordinator) self._person = person self._base = base self._attr_unique_id = f"{self.id}-valve" self._update_attr() @override 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)) @override 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 @override def _update_attr(self) -> None: """Handle updated coordinator data.""" self._static_attrs = self.reported_state self._attr_is_on = KEY_CURRENT_STATUS in self._static_attrs