Skip to content

Commit

Permalink
Add config flow for rachio
Browse files Browse the repository at this point in the history
Also discoverable via homekit
  • Loading branch information
bdraco committed Mar 13, 2020
1 parent 94b6ab2 commit cd1fdca
Show file tree
Hide file tree
Showing 13 changed files with 517 additions and 74 deletions.
2 changes: 1 addition & 1 deletion homeassistant/components/netatmo/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,4 @@
"Welcome"
]
}
}
}
24 changes: 24 additions & 0 deletions homeassistant/components/rachio/.translations/en.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{
"config": {
"title": "Rachio",
"step": {
"user": {
"title": "Connect to your Rachio device",
"description" : "You will need the API Key from https://app.rach.io/. Select 'Account Settings, and then click on 'GET API KEY'.",
"data": {
"api_key": "The API key for the Rachio account.",
"hass_url_override": "If your instance is unaware of its actual web location (base_url).",
"manual_run_mins": "For how long, in minutes, to turn on a station when the switch is enabled."
}
}
},
"error": {
"cannot_connect": "Failed to connect, please try again",
"invalid_auth": "Invalid authentication",
"unknown": "Unexpected error"
},
"abort": {
"already_configured": "Device is already configured"
}
}
}
166 changes: 120 additions & 46 deletions homeassistant/components/rachio/__init__.py
Original file line number Diff line number Diff line change
@@ -1,29 +1,45 @@
"""Integration with the Rachio Iro sprinkler system controller."""
import asyncio
import http.client
import logging
import secrets
import ssl
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.helpers import config_validation as cv, discovery
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.dispatcher import async_dispatcher_send

_LOGGER = logging.getLogger(__name__)
from .const import (
CONF_CUSTOM_URL,
CONF_MANUAL_RUN_MINS,
DEFAULT_MANUAL_RUN_MINS,
DOMAIN,
KEY_DEVICES,
KEY_ENABLED,
KEY_EXTERNAL_ID,
KEY_ID,
KEY_MAC_ADDRESS,
KEY_NAME,
KEY_SERIAL_NUMBER,
KEY_STATUS,
KEY_TYPE,
KEY_USERNAME,
KEY_ZONES,
)

DOMAIN = "rachio"
_LOGGER = logging.getLogger(__name__)

SUPPORTED_DOMAINS = ["switch", "binary_sensor"]

# Manual run length
CONF_MANUAL_RUN_MINS = "manual_run_mins"
DEFAULT_MANUAL_RUN_MINS = 10
CONF_CUSTOM_URL = "hass_url_override"

CONFIG_SCHEMA = vol.Schema(
{
DOMAIN: vol.Schema(
Expand All @@ -39,23 +55,6 @@
extra=vol.ALLOW_EXTRA,
)

# Keys used in the API JSON
KEY_DEVICE_ID = "deviceId"
KEY_DEVICES = "devices"
KEY_ENABLED = "enabled"
KEY_EXTERNAL_ID = "externalId"
KEY_ID = "id"
KEY_NAME = "name"
KEY_ON = "on"
KEY_STATUS = "status"
KEY_SUBTYPE = "subType"
KEY_SUMMARY = "summary"
KEY_TYPE = "type"
KEY_URL = "url"
KEY_USERNAME = "username"
KEY_ZONE_ID = "zoneId"
KEY_ZONE_NUMBER = "zoneNumber"
KEY_ZONES = "zones"

STATUS_ONLINE = "ONLINE"
STATUS_OFFLINE = "OFFLINE"
Expand Down Expand Up @@ -102,28 +101,62 @@
SIGNAL_RACHIO_SCHEDULE_UPDATE = SIGNAL_RACHIO_UPDATE + "_schedule"


def setup(hass, config) -> bool:
"""Set up the Rachio component."""
async def async_setup(hass: HomeAssistant, config: dict):
"""Set up the rachio component from YAML."""

conf = config.get(DOMAIN)
hass.data.setdefault(DOMAIN, {})

if not conf:
return True

hass.async_create_task(
hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_IMPORT}, data=conf
)
)
return True


async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry):
"""Unload a config entry."""
unload_ok = all(
await asyncio.gather(
*[
hass.config_entries.async_forward_entry_unload(entry, component)
for component in SUPPORTED_DOMAINS
]
)
)

if unload_ok:
hass.data[DOMAIN].pop(entry.entry_id)

return unload_ok


# Listen for incoming webhook connections
hass.http.register_view(RachioWebhookView())
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
"""Set up the Rachio config entry."""

config = entry.data

# Configure API
api_key = config[DOMAIN].get(CONF_API_KEY)
api_key = config.get(CONF_API_KEY)
rachio = Rachio(api_key)

# Get the URL of this server
custom_url = config[DOMAIN].get(CONF_CUSTOM_URL)
custom_url = config.get(CONF_CUSTOM_URL)
hass_url = hass.config.api.base_url if custom_url is None else custom_url
rachio.webhook_auth = secrets.token_hex()
rachio.webhook_url = hass_url + WEBHOOK_PATH

# Get the API user
try:
person = RachioPerson(hass, rachio, config[DOMAIN])
except AssertionError as error:
person = await hass.async_add_executor_job(RachioPerson, hass, rachio, config)
# Yes we really do get all these exceptions (hopefully rachiopy switches to requests)
except (http.client.HTTPException, ssl.SSLError, OSError, AssertionError) as error:
_LOGGER.error("Could not reach the Rachio API: %s", error)
return False
raise ConfigEntryNotReady

# Check for Rachio controller devices
if not person.controllers:
Expand All @@ -132,11 +165,15 @@ def setup(hass, config) -> bool:
_LOGGER.info("%d Rachio device(s) found", len(person.controllers))

# Enable component
hass.data[DOMAIN] = person
hass.data[DOMAIN][entry.entry_id] = person

# Listen for incoming webhook connections after the data is there
hass.http.register_view(RachioWebhookView(entry.entry_id))

# Load platforms
for component in SUPPORTED_DOMAINS:
discovery.load_platform(hass, component, DOMAIN, {}, config)
hass.async_create_task(
hass.config_entries.async_forward_entry_setup(entry, component)
)

return True

Expand All @@ -159,10 +196,25 @@ def __init__(self, hass, rachio, config):
data = rachio.person.get(self._id)
assert int(data[0][KEY_STATUS]) == 200, "User ID error"
self.username = data[1][KEY_USERNAME]
self._controllers = [
RachioIro(self._hass, self.rachio, controller)
for controller in data[1][KEY_DEVICES]
]
devices = data[1][KEY_DEVICES]
self._controllers = []
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
self._controllers.append(
RachioIro(self._hass, self.rachio, controller, webhooks)
)
_LOGGER.info('Using Rachio API as user "%s"', self.username)

@property
Expand All @@ -179,14 +231,17 @@ def controllers(self) -> list:
class RachioIro:
"""Represent a Rachio Iro."""

def __init__(self, hass, rachio, data):
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._zones = data[KEY_ZONES]
self._init_data = data
self._webhooks = webhooks
_LOGGER.debug('%s has ID "%s"', str(self), self.controller_id)

# Listen for all updates
Expand All @@ -199,13 +254,19 @@ def _init_webhooks(self) -> None:
# First delete any old webhooks that may have stuck around
def _deinit_webhooks(event) -> None:
"""Stop getting updates from the Rachio API."""
webhooks = self.rachio.notification.getDeviceWebhook(self.controller_id)[1]
for webhook in webhooks:
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)

Expand Down Expand Up @@ -234,6 +295,16 @@ def controller_id(self) -> str:
"""Return the Rachio API controller ID."""
return self._id

@property
def serial_number(self) -> str:
"""Return the Rachio API controller serial number."""
return self._serial_number

@property
def mac_address(self) -> str:
"""Return the Rachio API controller mac address."""
return self._mac_address

@property
def name(self) -> str:
"""Return the user-defined name of the controller."""
Expand Down Expand Up @@ -285,15 +356,18 @@ class RachioWebhookView(HomeAssistantView):
url = WEBHOOK_PATH
name = url[1:].replace("/", ":")

@asyncio.coroutine
def __init__(self, entry_id):
"""Initialize the instance of the view."""
self._entry_id = entry_id

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].rachio.webhook_auth
assert auth == hass.data[DOMAIN][self._entry_id].rachio.webhook_auth
except (AssertionError, IndexError):
return web.Response(status=web.HTTPForbidden.status_code)

Expand Down
38 changes: 30 additions & 8 deletions homeassistant/components/rachio/binary_sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,27 +6,33 @@
from homeassistant.helpers.dispatcher import dispatcher_connect

from . import (
DOMAIN as DOMAIN_RACHIO,
KEY_DEVICE_ID,
KEY_STATUS,
KEY_SUBTYPE,
SIGNAL_RACHIO_CONTROLLER_UPDATE,
STATUS_OFFLINE,
STATUS_ONLINE,
SUBTYPE_OFFLINE,
SUBTYPE_ONLINE,
)
from .const import (
DEFAULT_NAME,
DOMAIN as DOMAIN_RACHIO,
KEY_DEVICE_ID,
KEY_STATUS,
KEY_SUBTYPE,
)

_LOGGER = logging.getLogger(__name__)


def setup_platform(hass, config, add_entities, discovery_info=None):
async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up the Rachio binary sensors."""
devices = []
for controller in hass.data[DOMAIN_RACHIO].controllers:
devices.append(RachioControllerOnlineBinarySensor(hass, controller))
for controller in hass.data[DOMAIN_RACHIO][config_entry.entry_id].controllers:
sensor = await hass.async_add_executor_job(
RachioControllerOnlineBinarySensor, hass, controller
)
devices.append(sensor)

add_entities(devices)
async_add_entities(devices)
_LOGGER.info("%d Rachio binary sensor(s) added", len(devices))


Expand Down Expand Up @@ -70,6 +76,22 @@ def _poll_update(self, data=None) -> bool:
"""Request the state from the API."""
pass

@property
def device_info(self):
"""Return the device_info of the device."""
return {
"identifiers": {
(
DOMAIN_RACHIO,
self._controller.controller_id,
self._controller.serial_number,
self._controller.mac_address,
)
},
"name": self._controller.name,
"manufacturer": DEFAULT_NAME,
}

@abstractmethod
def _handle_update(self, *args, **kwargs) -> None:
"""Handle an update to the state of this sensor."""
Expand Down

0 comments on commit cd1fdca

Please sign in to comment.