Prepare rachio for cloudhooks conversion (#33422)
Reorganize code in order to prepare for webhooks
This commit is contained in:
parent
a473ae6711
commit
90dd796644
7 changed files with 377 additions and 319 deletions
|
@ -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)
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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"
|
||||
|
|
180
homeassistant/components/rachio/device.py
Normal file
180
homeassistant/components/rachio/device.py
Normal file
|
@ -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))
|
33
homeassistant/components/rachio/entity.py
Normal file
33
homeassistant/components/rachio/entity.py
Normal file
|
@ -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,
|
||||
}
|
|
@ -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()
|
||||
|
|
96
homeassistant/components/rachio/webhooks.py
Normal file
96
homeassistant/components/rachio/webhooks.py
Normal file
|
@ -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)
|
Loading…
Add table
Add a link
Reference in a new issue