Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add config flow for rachio #32757

Merged
merged 7 commits into from
Mar 14, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions CODEOWNERS
Validating CODEOWNERS rules …
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,7 @@ homeassistant/components/qnap/* @colinodell
homeassistant/components/quantum_gateway/* @cisasteelersfan
homeassistant/components/qvr_pro/* @oblogic7
homeassistant/components/qwikswitch/* @kellerza
homeassistant/components/rachio/* @bdraco
homeassistant/components/rainbird/* @konikvranik
homeassistant/components/raincloud/* @vanstinator
homeassistant/components/rainforest_eagle/* @gtdiehl @jcalbert
Expand Down
31 changes: 31 additions & 0 deletions homeassistant/components/rachio/.translations/en.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
{
"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."
}
}
},
"error": {
"cannot_connect": "Failed to connect, please try again",
"invalid_auth": "Invalid authentication",
"unknown": "Unexpected error"
},
"abort": {
"already_configured": "Device is already configured"
}
},
"options": {
"step": {
"init": {
"data": {
"manual_run_mins": "For how long, in minutes, to turn on a station when the switch is enabled."
}
}
}
}
}
183 changes: 132 additions & 51 deletions homeassistant/components/rachio/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,21 +9,36 @@
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,
RACHIO_API_EXCEPTIONS,
)

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 +54,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 +100,69 @@
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)

# Listen for incoming webhook connections
hass.http.register_view(RachioWebhookView())
return unload_ok


async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
"""Set up the Rachio config entry."""

config = entry.data
options = entry.options

# CONF_MANUAL_RUN_MINS can only come from a yaml import
if not options.get(CONF_MANUAL_RUN_MINS) and config.get(CONF_MANUAL_RUN_MINS):
options[CONF_MANUAL_RUN_MINS] = config[CONF_MANUAL_RUN_MINS]
bdraco marked this conversation as resolved.
Show resolved Hide resolved

# Configure API
api_key = config[DOMAIN].get(CONF_API_KEY)
api_key = config.get(CONF_API_KEY)
bdraco marked this conversation as resolved.
Show resolved Hide resolved
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
bdraco marked this conversation as resolved.
Show resolved Hide resolved
rachio.webhook_auth = secrets.token_hex()
rachio.webhook_url = hass_url + WEBHOOK_PATH
webhook_url_path = f"{WEBHOOK_PATH}-{entry.entry_id}"
rachio.webhook_url = f"{hass_url}{webhook_url_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, entry)
# Yes we really do get all these exceptions (hopefully rachiopy switches to requests)
# and there is not a reasonable timeout here so it can block for a long time
except RACHIO_API_EXCEPTIONS 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,24 +171,28 @@ 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, webhook_url_path))

# 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


class RachioPerson:
"""Represent a Rachio user."""

def __init__(self, hass, rachio, config):
def __init__(self, hass, rachio, config_entry):
"""Create an object from the provided API instance."""
# Use API token to get user ID
self._hass = hass
self.rachio = rachio
self.config = config
self.config_entry = config_entry

response = rachio.person.getInfo()
assert int(response[0][KEY_STATUS]) == 200, "API key error"
Expand All @@ -159,10 +202,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]
bdraco marked this conversation as resolved.
Show resolved Hide resolved
# 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 +237,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 +260,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 +301,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
bdraco marked this conversation as resolved.
Show resolved Hide resolved

@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 @@ -282,18 +359,22 @@ class RachioWebhookView(HomeAssistantView):
}

requires_auth = False # Handled separately
url = WEBHOOK_PATH
name = url[1:].replace("/", ":")

@asyncio.coroutine
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("Created webhook at url: %s, with name %s", self.url, self.name)
bdraco marked this conversation as resolved.
Show resolved Hide resolved

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