From 90dd796644e459c7dd14df630d49c264949e46b9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 31 Mar 2020 16:46:30 -0500 Subject: [PATCH] Prepare rachio for cloudhooks conversion (#33422) Reorganize code in order to prepare for webhooks --- homeassistant/components/rachio/__init__.py | 286 +----------------- .../components/rachio/binary_sensor.py | 37 +-- homeassistant/components/rachio/const.py | 13 + homeassistant/components/rachio/device.py | 180 +++++++++++ homeassistant/components/rachio/entity.py | 33 ++ homeassistant/components/rachio/switch.py | 51 ++-- homeassistant/components/rachio/webhooks.py | 96 ++++++ 7 files changed, 377 insertions(+), 319 deletions(-) create mode 100644 homeassistant/components/rachio/device.py create mode 100644 homeassistant/components/rachio/entity.py create mode 100644 homeassistant/components/rachio/webhooks.py diff --git a/homeassistant/components/rachio/__init__.py b/homeassistant/components/rachio/__init__.py index 7eaa76dedd4..9bd3b16d12c 100644 --- a/homeassistant/components/rachio/__init__.py +++ b/homeassistant/components/rachio/__init__.py @@ -2,41 +2,25 @@ import asyncio import logging import secrets -from typing import Optional -from aiohttp import web from rachiopy import Rachio import voluptuous as vol -from homeassistant.components.http import HomeAssistantView from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import CONF_API_KEY, EVENT_HOMEASSISTANT_STOP, URL_API +from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import config_validation as cv, device_registry -from homeassistant.helpers.dispatcher import async_dispatcher_send -from homeassistant.helpers.entity import Entity +from homeassistant.helpers import config_validation as cv from .const import ( CONF_CUSTOM_URL, CONF_MANUAL_RUN_MINS, DEFAULT_MANUAL_RUN_MINS, - DEFAULT_NAME, DOMAIN, - KEY_DEVICES, - KEY_ENABLED, - KEY_EXTERNAL_ID, - KEY_ID, - KEY_MAC_ADDRESS, - KEY_MODEL, - KEY_NAME, - KEY_SERIAL_NUMBER, - KEY_STATUS, - KEY_TYPE, - KEY_USERNAME, - KEY_ZONES, RACHIO_API_EXCEPTIONS, ) +from .device import RachioPerson +from .webhooks import WEBHOOK_PATH, RachioWebhookView _LOGGER = logging.getLogger(__name__) @@ -58,51 +42,6 @@ CONFIG_SCHEMA = vol.Schema( ) -STATUS_ONLINE = "ONLINE" -STATUS_OFFLINE = "OFFLINE" - -# Device webhook values -TYPE_CONTROLLER_STATUS = "DEVICE_STATUS" -SUBTYPE_OFFLINE = "OFFLINE" -SUBTYPE_ONLINE = "ONLINE" -SUBTYPE_OFFLINE_NOTIFICATION = "OFFLINE_NOTIFICATION" -SUBTYPE_COLD_REBOOT = "COLD_REBOOT" -SUBTYPE_SLEEP_MODE_ON = "SLEEP_MODE_ON" -SUBTYPE_SLEEP_MODE_OFF = "SLEEP_MODE_OFF" -SUBTYPE_BROWNOUT_VALVE = "BROWNOUT_VALVE" -SUBTYPE_RAIN_SENSOR_DETECTION_ON = "RAIN_SENSOR_DETECTION_ON" -SUBTYPE_RAIN_SENSOR_DETECTION_OFF = "RAIN_SENSOR_DETECTION_OFF" -SUBTYPE_RAIN_DELAY_ON = "RAIN_DELAY_ON" -SUBTYPE_RAIN_DELAY_OFF = "RAIN_DELAY_OFF" - -# Schedule webhook values -TYPE_SCHEDULE_STATUS = "SCHEDULE_STATUS" -SUBTYPE_SCHEDULE_STARTED = "SCHEDULE_STARTED" -SUBTYPE_SCHEDULE_STOPPED = "SCHEDULE_STOPPED" -SUBTYPE_SCHEDULE_COMPLETED = "SCHEDULE_COMPLETED" -SUBTYPE_WEATHER_NO_SKIP = "WEATHER_INTELLIGENCE_NO_SKIP" -SUBTYPE_WEATHER_SKIP = "WEATHER_INTELLIGENCE_SKIP" -SUBTYPE_WEATHER_CLIMATE_SKIP = "WEATHER_INTELLIGENCE_CLIMATE_SKIP" -SUBTYPE_WEATHER_FREEZE = "WEATHER_INTELLIGENCE_FREEZE" - -# Zone webhook values -TYPE_ZONE_STATUS = "ZONE_STATUS" -SUBTYPE_ZONE_STARTED = "ZONE_STARTED" -SUBTYPE_ZONE_STOPPED = "ZONE_STOPPED" -SUBTYPE_ZONE_COMPLETED = "ZONE_COMPLETED" -SUBTYPE_ZONE_CYCLING = "ZONE_CYCLING" -SUBTYPE_ZONE_CYCLING_COMPLETED = "ZONE_CYCLING_COMPLETED" - -# Webhook callbacks -LISTEN_EVENT_TYPES = ["DEVICE_STATUS_EVENT", "ZONE_STATUS_EVENT"] -WEBHOOK_CONST_ID = "homeassistant.rachio:" -WEBHOOK_PATH = URL_API + DOMAIN -SIGNAL_RACHIO_UPDATE = DOMAIN + "_update" -SIGNAL_RACHIO_CONTROLLER_UPDATE = SIGNAL_RACHIO_UPDATE + "_controller" -SIGNAL_RACHIO_ZONE_UPDATE = SIGNAL_RACHIO_UPDATE + "_zone" -SIGNAL_RACHIO_SCHEDULE_UPDATE = SIGNAL_RACHIO_UPDATE + "_schedule" - - async def async_setup(hass: HomeAssistant, config: dict): """Set up the rachio component from YAML.""" @@ -189,220 +128,3 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): ) return True - - -class RachioPerson: - """Represent a Rachio user.""" - - def __init__(self, rachio, config_entry): - """Create an object from the provided API instance.""" - # Use API token to get user ID - self.rachio = rachio - self.config_entry = config_entry - self.username = None - self._id = None - self._controllers = [] - - def setup(self, hass): - """Rachio device setup.""" - response = self.rachio.person.getInfo() - assert int(response[0][KEY_STATUS]) == 200, "API key error" - self._id = response[1][KEY_ID] - - # Use user ID to get user data - data = self.rachio.person.get(self._id) - assert int(data[0][KEY_STATUS]) == 200, "User ID error" - self.username = data[1][KEY_USERNAME] - devices = data[1][KEY_DEVICES] - for controller in devices: - webhooks = self.rachio.notification.getDeviceWebhook(controller[KEY_ID])[1] - # The API does not provide a way to tell if a controller is shared - # or if they are the owner. To work around this problem we fetch the webooks - # before we setup the device so we can skip it instead of failing. - # webhooks are normally a list, however if there is an error - # rachio hands us back a dict - if isinstance(webhooks, dict): - _LOGGER.error( - "Failed to add rachio controller '%s' because of an error: %s", - controller[KEY_NAME], - webhooks.get("error", "Unknown Error"), - ) - continue - - rachio_iro = RachioIro(hass, self.rachio, controller, webhooks) - rachio_iro.setup() - self._controllers.append(rachio_iro) - _LOGGER.info('Using Rachio API as user "%s"', self.username) - - @property - def user_id(self) -> str: - """Get the user ID as defined by the Rachio API.""" - return self._id - - @property - def controllers(self) -> list: - """Get a list of controllers managed by this account.""" - return self._controllers - - -class RachioIro: - """Represent a Rachio Iro.""" - - def __init__(self, hass, rachio, data, webhooks): - """Initialize a Rachio device.""" - self.hass = hass - self.rachio = rachio - self._id = data[KEY_ID] - self.name = data[KEY_NAME] - self.serial_number = data[KEY_SERIAL_NUMBER] - self.mac_address = data[KEY_MAC_ADDRESS] - self.model = data[KEY_MODEL] - self._zones = data[KEY_ZONES] - self._init_data = data - self._webhooks = webhooks - _LOGGER.debug('%s has ID "%s"', str(self), self.controller_id) - - def setup(self): - """Rachio Iro setup for webhooks.""" - # Listen for all updates - self._init_webhooks() - - def _init_webhooks(self) -> None: - """Start getting updates from the Rachio API.""" - current_webhook_id = None - - # First delete any old webhooks that may have stuck around - def _deinit_webhooks(event) -> None: - """Stop getting updates from the Rachio API.""" - if not self._webhooks: - # We fetched webhooks when we created the device, however if we call _init_webhooks - # again we need to fetch again - self._webhooks = self.rachio.notification.getDeviceWebhook( - self.controller_id - )[1] - for webhook in self._webhooks: - if ( - webhook[KEY_EXTERNAL_ID].startswith(WEBHOOK_CONST_ID) - or webhook[KEY_ID] == current_webhook_id - ): - self.rachio.notification.deleteWebhook(webhook[KEY_ID]) - self._webhooks = None - - _deinit_webhooks(None) - - # Choose which events to listen for and get their IDs - event_types = [] - for event_type in self.rachio.notification.getWebhookEventType()[1]: - if event_type[KEY_NAME] in LISTEN_EVENT_TYPES: - event_types.append({"id": event_type[KEY_ID]}) - - # Register to listen to these events from the device - url = self.rachio.webhook_url - auth = WEBHOOK_CONST_ID + self.rachio.webhook_auth - new_webhook = self.rachio.notification.postWebhook( - self.controller_id, auth, url, event_types - ) - # Save ID for deletion at shutdown - current_webhook_id = new_webhook[1][KEY_ID] - self.hass.bus.listen(EVENT_HOMEASSISTANT_STOP, _deinit_webhooks) - - def __str__(self) -> str: - """Display the controller as a string.""" - return f'Rachio controller "{self.name}"' - - @property - def controller_id(self) -> str: - """Return the Rachio API controller ID.""" - return self._id - - @property - def current_schedule(self) -> str: - """Return the schedule that the device is running right now.""" - return self.rachio.device.getCurrentSchedule(self.controller_id)[1] - - @property - def init_data(self) -> dict: - """Return the information used to set up the controller.""" - return self._init_data - - def list_zones(self, include_disabled=False) -> list: - """Return a list of the zone dicts connected to the device.""" - # All zones - if include_disabled: - return self._zones - - # Only enabled zones - return [z for z in self._zones if z[KEY_ENABLED]] - - def get_zone(self, zone_id) -> Optional[dict]: - """Return the zone with the given ID.""" - for zone in self.list_zones(include_disabled=True): - if zone[KEY_ID] == zone_id: - return zone - - return None - - def stop_watering(self) -> None: - """Stop watering all zones connected to this controller.""" - self.rachio.device.stopWater(self.controller_id) - _LOGGER.info("Stopped watering of all zones on %s", str(self)) - - -class RachioDeviceInfoProvider(Entity): - """Mixin to provide device_info.""" - - def __init__(self, controller): - """Initialize a Rachio device.""" - super().__init__() - self._controller = controller - - @property - def device_info(self): - """Return the device_info of the device.""" - return { - "identifiers": {(DOMAIN, self._controller.serial_number,)}, - "connections": { - (device_registry.CONNECTION_NETWORK_MAC, self._controller.mac_address,) - }, - "name": self._controller.name, - "model": self._controller.model, - "manufacturer": DEFAULT_NAME, - } - - -class RachioWebhookView(HomeAssistantView): - """Provide a page for the server to call.""" - - SIGNALS = { - TYPE_CONTROLLER_STATUS: SIGNAL_RACHIO_CONTROLLER_UPDATE, - TYPE_SCHEDULE_STATUS: SIGNAL_RACHIO_SCHEDULE_UPDATE, - TYPE_ZONE_STATUS: SIGNAL_RACHIO_ZONE_UPDATE, - } - - requires_auth = False # Handled separately - - def __init__(self, entry_id, webhook_url): - """Initialize the instance of the view.""" - self._entry_id = entry_id - self.url = webhook_url - self.name = webhook_url[1:].replace("/", ":") - _LOGGER.debug( - "Initialize webhook at url: %s, with name %s", self.url, self.name - ) - - async def post(self, request) -> web.Response: - """Handle webhook calls from the server.""" - hass = request.app["hass"] - data = await request.json() - - try: - auth = data.get(KEY_EXTERNAL_ID, str()).split(":")[1] - assert auth == hass.data[DOMAIN][self._entry_id].rachio.webhook_auth - except (AssertionError, IndexError): - return web.Response(status=web.HTTPForbidden.status_code) - - update_type = data[KEY_TYPE] - if update_type in self.SIGNALS: - async_dispatcher_send(hass, self.SIGNALS[update_type], data) - - return web.Response(status=web.HTTPNoContent.status_code) diff --git a/homeassistant/components/rachio/binary_sensor.py b/homeassistant/components/rachio/binary_sensor.py index 43ee9650163..ab3a0b91276 100644 --- a/homeassistant/components/rachio/binary_sensor.py +++ b/homeassistant/components/rachio/binary_sensor.py @@ -2,18 +2,23 @@ from abc import abstractmethod import logging -from homeassistant.components.binary_sensor import BinarySensorDevice +from homeassistant.components.binary_sensor import ( + DEVICE_CLASS_CONNECTIVITY, + BinarySensorDevice, +) from homeassistant.helpers.dispatcher import async_dispatcher_connect -from . import ( +from .const import ( + DOMAIN as DOMAIN_RACHIO, + KEY_DEVICE_ID, + KEY_STATUS, + KEY_SUBTYPE, SIGNAL_RACHIO_CONTROLLER_UPDATE, STATUS_OFFLINE, STATUS_ONLINE, - SUBTYPE_OFFLINE, - SUBTYPE_ONLINE, - RachioDeviceInfoProvider, ) -from .const import DOMAIN as DOMAIN_RACHIO, KEY_DEVICE_ID, KEY_STATUS, KEY_SUBTYPE +from .entity import RachioDevice +from .webhooks import SUBTYPE_OFFLINE, SUBTYPE_ONLINE _LOGGER = logging.getLogger(__name__) @@ -32,23 +37,18 @@ def _create_entities(hass, config_entry): return entities -class RachioControllerBinarySensor(RachioDeviceInfoProvider, BinarySensorDevice): +class RachioControllerBinarySensor(RachioDevice, BinarySensorDevice): """Represent a binary sensor that reflects a Rachio state.""" def __init__(self, controller, poll=True): """Set up a new Rachio controller binary sensor.""" super().__init__(controller) - + self._undo_dispatcher = None if poll: self._state = self._poll_update() else: self._state = None - @property - def should_poll(self) -> bool: - """Declare that this entity pushes its state to HA.""" - return False - @property def is_on(self) -> bool: """Return whether the sensor has a 'true' value.""" @@ -66,19 +66,22 @@ class RachioControllerBinarySensor(RachioDeviceInfoProvider, BinarySensorDevice) @abstractmethod def _poll_update(self, data=None) -> bool: """Request the state from the API.""" - pass @abstractmethod def _handle_update(self, *args, **kwargs) -> None: """Handle an update to the state of this sensor.""" - pass async def async_added_to_hass(self): """Subscribe to updates.""" - async_dispatcher_connect( + self._undo_dispatcher = async_dispatcher_connect( self.hass, SIGNAL_RACHIO_CONTROLLER_UPDATE, self._handle_any_update ) + async def async_will_remove_from_hass(self): + """Unsubscribe from updates.""" + if self._undo_dispatcher: + self._undo_dispatcher() + class RachioControllerOnlineBinarySensor(RachioControllerBinarySensor): """Represent a binary sensor that reflects if the controller is online.""" @@ -101,7 +104,7 @@ class RachioControllerOnlineBinarySensor(RachioControllerBinarySensor): @property def device_class(self) -> str: """Return the class of this device, from component DEVICE_CLASSES.""" - return "connectivity" + return DEVICE_CLASS_CONNECTIVITY @property def icon(self) -> str: diff --git a/homeassistant/components/rachio/const.py b/homeassistant/components/rachio/const.py index fb66d4378f1..13e8029b512 100644 --- a/homeassistant/components/rachio/const.py +++ b/homeassistant/components/rachio/const.py @@ -33,6 +33,11 @@ KEY_USERNAME = "username" KEY_ZONE_ID = "zoneId" KEY_ZONE_NUMBER = "zoneNumber" KEY_ZONES = "zones" +KEY_CUSTOM_SHADE = "customShade" +KEY_CUSTOM_CROP = "customCrop" + +ATTR_ZONE_TYPE = "type" +ATTR_ZONE_SHADE = "shade" # Yes we really do get all these exceptions (hopefully rachiopy switches to requests) RACHIO_API_EXCEPTIONS = ( @@ -41,3 +46,11 @@ RACHIO_API_EXCEPTIONS = ( OSError, AssertionError, ) + +STATUS_ONLINE = "ONLINE" +STATUS_OFFLINE = "OFFLINE" + +SIGNAL_RACHIO_UPDATE = DOMAIN + "_update" +SIGNAL_RACHIO_CONTROLLER_UPDATE = SIGNAL_RACHIO_UPDATE + "_controller" +SIGNAL_RACHIO_ZONE_UPDATE = SIGNAL_RACHIO_UPDATE + "_zone" +SIGNAL_RACHIO_SCHEDULE_UPDATE = SIGNAL_RACHIO_UPDATE + "_schedule" diff --git a/homeassistant/components/rachio/device.py b/homeassistant/components/rachio/device.py new file mode 100644 index 00000000000..949957ae8ec --- /dev/null +++ b/homeassistant/components/rachio/device.py @@ -0,0 +1,180 @@ +"""Adapter to wrap the rachiopy api for home assistant.""" + +import logging +from typing import Optional + +from homeassistant.const import EVENT_HOMEASSISTANT_STOP + +from .const import ( + KEY_DEVICES, + KEY_ENABLED, + KEY_EXTERNAL_ID, + KEY_ID, + KEY_MAC_ADDRESS, + KEY_MODEL, + KEY_NAME, + KEY_SERIAL_NUMBER, + KEY_STATUS, + KEY_USERNAME, + KEY_ZONES, +) +from .webhooks import LISTEN_EVENT_TYPES, WEBHOOK_CONST_ID + +_LOGGER = logging.getLogger(__name__) + + +class RachioPerson: + """Represent a Rachio user.""" + + def __init__(self, rachio, config_entry): + """Create an object from the provided API instance.""" + # Use API token to get user ID + self.rachio = rachio + self.config_entry = config_entry + self.username = None + self._id = None + self._controllers = [] + + def setup(self, hass): + """Rachio device setup.""" + response = self.rachio.person.getInfo() + assert int(response[0][KEY_STATUS]) == 200, "API key error" + self._id = response[1][KEY_ID] + + # Use user ID to get user data + data = self.rachio.person.get(self._id) + assert int(data[0][KEY_STATUS]) == 200, "User ID error" + self.username = data[1][KEY_USERNAME] + devices = data[1][KEY_DEVICES] + for controller in devices: + webhooks = self.rachio.notification.getDeviceWebhook(controller[KEY_ID])[1] + # The API does not provide a way to tell if a controller is shared + # or if they are the owner. To work around this problem we fetch the webooks + # before we setup the device so we can skip it instead of failing. + # webhooks are normally a list, however if there is an error + # rachio hands us back a dict + if isinstance(webhooks, dict): + _LOGGER.error( + "Failed to add rachio controller '%s' because of an error: %s", + controller[KEY_NAME], + webhooks.get("error", "Unknown Error"), + ) + continue + + rachio_iro = RachioIro(hass, self.rachio, controller, webhooks) + rachio_iro.setup() + self._controllers.append(rachio_iro) + _LOGGER.info('Using Rachio API as user "%s"', self.username) + + @property + def user_id(self) -> str: + """Get the user ID as defined by the Rachio API.""" + return self._id + + @property + def controllers(self) -> list: + """Get a list of controllers managed by this account.""" + return self._controllers + + +class RachioIro: + """Represent a Rachio Iro.""" + + def __init__(self, hass, rachio, data, webhooks): + """Initialize a Rachio device.""" + self.hass = hass + self.rachio = rachio + self._id = data[KEY_ID] + self.name = data[KEY_NAME] + self.serial_number = data[KEY_SERIAL_NUMBER] + self.mac_address = data[KEY_MAC_ADDRESS] + self.model = data[KEY_MODEL] + self._zones = data[KEY_ZONES] + self._init_data = data + self._webhooks = webhooks + _LOGGER.debug('%s has ID "%s"', str(self), self.controller_id) + + def setup(self): + """Rachio Iro setup for webhooks.""" + # Listen for all updates + self._init_webhooks() + + def _init_webhooks(self) -> None: + """Start getting updates from the Rachio API.""" + current_webhook_id = None + + # First delete any old webhooks that may have stuck around + def _deinit_webhooks(_) -> None: + """Stop getting updates from the Rachio API.""" + if not self._webhooks: + # We fetched webhooks when we created the device, however if we call _init_webhooks + # again we need to fetch again + self._webhooks = self.rachio.notification.getDeviceWebhook( + self.controller_id + )[1] + for webhook in self._webhooks: + if ( + webhook[KEY_EXTERNAL_ID].startswith(WEBHOOK_CONST_ID) + or webhook[KEY_ID] == current_webhook_id + ): + self.rachio.notification.deleteWebhook(webhook[KEY_ID]) + self._webhooks = None + + _deinit_webhooks(None) + + # Choose which events to listen for and get their IDs + event_types = [] + for event_type in self.rachio.notification.getWebhookEventType()[1]: + if event_type[KEY_NAME] in LISTEN_EVENT_TYPES: + event_types.append({"id": event_type[KEY_ID]}) + + # Register to listen to these events from the device + url = self.rachio.webhook_url + auth = WEBHOOK_CONST_ID + self.rachio.webhook_auth + new_webhook = self.rachio.notification.postWebhook( + self.controller_id, auth, url, event_types + ) + # Save ID for deletion at shutdown + current_webhook_id = new_webhook[1][KEY_ID] + self.hass.bus.listen(EVENT_HOMEASSISTANT_STOP, _deinit_webhooks) + + def __str__(self) -> str: + """Display the controller as a string.""" + return f'Rachio controller "{self.name}"' + + @property + def controller_id(self) -> str: + """Return the Rachio API controller ID.""" + return self._id + + @property + def current_schedule(self) -> str: + """Return the schedule that the device is running right now.""" + return self.rachio.device.getCurrentSchedule(self.controller_id)[1] + + @property + def init_data(self) -> dict: + """Return the information used to set up the controller.""" + return self._init_data + + def list_zones(self, include_disabled=False) -> list: + """Return a list of the zone dicts connected to the device.""" + # All zones + if include_disabled: + return self._zones + + # Only enabled zones + return [z for z in self._zones if z[KEY_ENABLED]] + + def get_zone(self, zone_id) -> Optional[dict]: + """Return the zone with the given ID.""" + for zone in self.list_zones(include_disabled=True): + if zone[KEY_ID] == zone_id: + return zone + + return None + + def stop_watering(self) -> None: + """Stop watering all zones connected to this controller.""" + self.rachio.device.stopWater(self.controller_id) + _LOGGER.info("Stopped watering of all zones on %s", str(self)) diff --git a/homeassistant/components/rachio/entity.py b/homeassistant/components/rachio/entity.py new file mode 100644 index 00000000000..379c4e785e5 --- /dev/null +++ b/homeassistant/components/rachio/entity.py @@ -0,0 +1,33 @@ +"""Adapter to wrap the rachiopy api for home assistant.""" + +from homeassistant.helpers import device_registry +from homeassistant.helpers.entity import Entity + +from .const import DEFAULT_NAME, DOMAIN + + +class RachioDevice(Entity): + """Base class for rachio devices.""" + + def __init__(self, controller): + """Initialize a Rachio device.""" + super().__init__() + self._controller = controller + + @property + def should_poll(self) -> bool: + """Declare that this entity pushes its state to HA.""" + return False + + @property + def device_info(self): + """Return the device_info of the device.""" + return { + "identifiers": {(DOMAIN, self._controller.serial_number,)}, + "connections": { + (device_registry.CONNECTION_NETWORK_MAC, self._controller.mac_address,) + }, + "name": self._controller.name, + "model": self._controller.model, + "manufacturer": DEFAULT_NAME, + } diff --git a/homeassistant/components/rachio/switch.py b/homeassistant/components/rachio/switch.py index 5320d434d00..5df084a11a4 100644 --- a/homeassistant/components/rachio/switch.py +++ b/homeassistant/components/rachio/switch.py @@ -6,20 +6,14 @@ import logging from homeassistant.components.switch import SwitchDevice from homeassistant.helpers.dispatcher import async_dispatcher_connect -from . import ( - SIGNAL_RACHIO_CONTROLLER_UPDATE, - SIGNAL_RACHIO_ZONE_UPDATE, - SUBTYPE_SLEEP_MODE_OFF, - SUBTYPE_SLEEP_MODE_ON, - SUBTYPE_ZONE_COMPLETED, - SUBTYPE_ZONE_STARTED, - SUBTYPE_ZONE_STOPPED, - RachioDeviceInfoProvider, -) from .const import ( + ATTR_ZONE_SHADE, + ATTR_ZONE_TYPE, CONF_MANUAL_RUN_MINS, DEFAULT_MANUAL_RUN_MINS, DOMAIN as DOMAIN_RACHIO, + KEY_CUSTOM_CROP, + KEY_CUSTOM_SHADE, KEY_DEVICE_ID, KEY_ENABLED, KEY_ID, @@ -30,6 +24,16 @@ from .const import ( KEY_SUMMARY, KEY_ZONE_ID, KEY_ZONE_NUMBER, + SIGNAL_RACHIO_CONTROLLER_UPDATE, + SIGNAL_RACHIO_ZONE_UPDATE, +) +from .entity import RachioDevice +from .webhooks import ( + SUBTYPE_SLEEP_MODE_OFF, + SUBTYPE_SLEEP_MODE_ON, + SUBTYPE_ZONE_COMPLETED, + SUBTYPE_ZONE_STARTED, + SUBTYPE_ZONE_STOPPED, ) _LOGGER = logging.getLogger(__name__) @@ -62,7 +66,7 @@ def _create_entities(hass, config_entry): return entities -class RachioSwitch(RachioDeviceInfoProvider, SwitchDevice): +class RachioSwitch(RachioDevice, SwitchDevice): """Represent a Rachio state that can be toggled.""" def __init__(self, controller, poll=True): @@ -74,11 +78,6 @@ class RachioSwitch(RachioDeviceInfoProvider, SwitchDevice): else: self._state = None - @property - def should_poll(self) -> bool: - """Declare that this entity pushes its state to HA.""" - return False - @property def name(self) -> str: """Get a name for this switch.""" @@ -92,7 +91,6 @@ class RachioSwitch(RachioDeviceInfoProvider, SwitchDevice): @abstractmethod def _poll_update(self, data=None) -> bool: """Poll the API.""" - pass def _handle_any_update(self, *args, **kwargs) -> None: """Determine whether an update event applies to this device.""" @@ -106,7 +104,6 @@ class RachioSwitch(RachioDeviceInfoProvider, SwitchDevice): @abstractmethod def _handle_update(self, *args, **kwargs) -> None: """Handle incoming webhook data.""" - pass class RachioStandbySwitch(RachioSwitch): @@ -169,15 +166,19 @@ class RachioZone(RachioSwitch): def __init__(self, person, controller, data, current_schedule): """Initialize a new Rachio Zone.""" self._id = data[KEY_ID] + _LOGGER.debug("zone_data: %s", data) self._zone_name = data[KEY_NAME] self._zone_number = data[KEY_ZONE_NUMBER] self._zone_enabled = data[KEY_ENABLED] self._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._summary = str() self._current_schedule = current_schedule super().__init__(controller, poll=False) self._state = self.zone_id == self._current_schedule.get(KEY_ZONE_ID) + self._undo_dispatcher = None def __str__(self): """Display the zone as a string.""" @@ -216,7 +217,12 @@ class RachioZone(RachioSwitch): @property def state_attributes(self) -> dict: """Return the optional state attributes.""" - return {ATTR_ZONE_NUMBER: self._zone_number, ATTR_ZONE_SUMMARY: self._summary} + 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 + return props def turn_on(self, **kwargs) -> None: """Start watering this zone.""" @@ -262,6 +268,11 @@ class RachioZone(RachioSwitch): async def async_added_to_hass(self): """Subscribe to updates.""" - async_dispatcher_connect( + self._undo_dispatcher = async_dispatcher_connect( self.hass, SIGNAL_RACHIO_ZONE_UPDATE, self._handle_update ) + + async def async_will_remove_from_hass(self): + """Unsubscribe from updates.""" + if self._undo_dispatcher: + self._undo_dispatcher() diff --git a/homeassistant/components/rachio/webhooks.py b/homeassistant/components/rachio/webhooks.py new file mode 100644 index 00000000000..c12f2ccfd3e --- /dev/null +++ b/homeassistant/components/rachio/webhooks.py @@ -0,0 +1,96 @@ +"""Webhooks used by rachio.""" + +import logging + +from aiohttp import web + +from homeassistant.components.http import HomeAssistantView +from homeassistant.const import URL_API +from homeassistant.helpers.dispatcher import async_dispatcher_send + +from .const import ( + DOMAIN, + KEY_EXTERNAL_ID, + KEY_TYPE, + SIGNAL_RACHIO_CONTROLLER_UPDATE, + SIGNAL_RACHIO_SCHEDULE_UPDATE, + SIGNAL_RACHIO_ZONE_UPDATE, +) + +# Device webhook values +TYPE_CONTROLLER_STATUS = "DEVICE_STATUS" +SUBTYPE_OFFLINE = "OFFLINE" +SUBTYPE_ONLINE = "ONLINE" +SUBTYPE_OFFLINE_NOTIFICATION = "OFFLINE_NOTIFICATION" +SUBTYPE_COLD_REBOOT = "COLD_REBOOT" +SUBTYPE_SLEEP_MODE_ON = "SLEEP_MODE_ON" +SUBTYPE_SLEEP_MODE_OFF = "SLEEP_MODE_OFF" +SUBTYPE_BROWNOUT_VALVE = "BROWNOUT_VALVE" +SUBTYPE_RAIN_SENSOR_DETECTION_ON = "RAIN_SENSOR_DETECTION_ON" +SUBTYPE_RAIN_SENSOR_DETECTION_OFF = "RAIN_SENSOR_DETECTION_OFF" +SUBTYPE_RAIN_DELAY_ON = "RAIN_DELAY_ON" +SUBTYPE_RAIN_DELAY_OFF = "RAIN_DELAY_OFF" + +# Schedule webhook values +TYPE_SCHEDULE_STATUS = "SCHEDULE_STATUS" +SUBTYPE_SCHEDULE_STARTED = "SCHEDULE_STARTED" +SUBTYPE_SCHEDULE_STOPPED = "SCHEDULE_STOPPED" +SUBTYPE_SCHEDULE_COMPLETED = "SCHEDULE_COMPLETED" +SUBTYPE_WEATHER_NO_SKIP = "WEATHER_INTELLIGENCE_NO_SKIP" +SUBTYPE_WEATHER_SKIP = "WEATHER_INTELLIGENCE_SKIP" +SUBTYPE_WEATHER_CLIMATE_SKIP = "WEATHER_INTELLIGENCE_CLIMATE_SKIP" +SUBTYPE_WEATHER_FREEZE = "WEATHER_INTELLIGENCE_FREEZE" + +# Zone webhook values +TYPE_ZONE_STATUS = "ZONE_STATUS" +SUBTYPE_ZONE_STARTED = "ZONE_STARTED" +SUBTYPE_ZONE_STOPPED = "ZONE_STOPPED" +SUBTYPE_ZONE_COMPLETED = "ZONE_COMPLETED" +SUBTYPE_ZONE_CYCLING = "ZONE_CYCLING" +SUBTYPE_ZONE_CYCLING_COMPLETED = "ZONE_CYCLING_COMPLETED" + +# Webhook callbacks +LISTEN_EVENT_TYPES = ["DEVICE_STATUS_EVENT", "ZONE_STATUS_EVENT"] +WEBHOOK_CONST_ID = "homeassistant.rachio:" +WEBHOOK_PATH = URL_API + DOMAIN + +SIGNAL_MAP = { + TYPE_CONTROLLER_STATUS: SIGNAL_RACHIO_CONTROLLER_UPDATE, + TYPE_SCHEDULE_STATUS: SIGNAL_RACHIO_SCHEDULE_UPDATE, + TYPE_ZONE_STATUS: SIGNAL_RACHIO_ZONE_UPDATE, +} + + +_LOGGER = logging.getLogger(__name__) + + +class RachioWebhookView(HomeAssistantView): + """Provide a page for the server to call.""" + + requires_auth = False # Handled separately + + def __init__(self, entry_id, webhook_url): + """Initialize the instance of the view.""" + self._entry_id = entry_id + self.url = webhook_url + self.name = webhook_url[1:].replace("/", ":") + _LOGGER.debug( + "Initialize webhook at url: %s, with name %s", self.url, self.name + ) + + async def post(self, request) -> web.Response: + """Handle webhook calls from the server.""" + hass = request.app["hass"] + data = await request.json() + + try: + auth = data.get(KEY_EXTERNAL_ID, str()).split(":")[1] + assert auth == hass.data[DOMAIN][self._entry_id].rachio.webhook_auth + except (AssertionError, IndexError): + return web.Response(status=web.HTTPForbidden.status_code) + + update_type = data[KEY_TYPE] + if update_type in SIGNAL_MAP: + async_dispatcher_send(hass, SIGNAL_MAP[update_type], data) + + return web.Response(status=web.HTTPNoContent.status_code)