mirror of
https://github.com/home-assistant/core.git
synced 2025-04-23 00:37:53 +00:00
Add config flow for rachio (#32757)
* Do not fail when a user has a controller with shared access on their account * Add config flow for rachio Also discoverable via homekit * Update homeassistant/components/rachio/switch.py Co-Authored-By: Paulus Schoutsen <paulus@home-assistant.io> * Split setting the default run time to an options flow Ensue the run time coming from yaml gets imported into the option flow Only get the schedule once at setup instead of each zone (was hitting rate limits) Add the config entry id to the end of the webhook so there is a unique hook per config entry Breakout the slew of exceptions rachiopy can throw into RachioAPIExceptions Remove the base url override as an option for the config flow Switch identifer for device_info to serial number Add connections to device_info (mac address) * rename to make pylint happy * Fix import of custom_url * claim rachio Co-authored-by: Paulus Schoutsen <paulus@home-assistant.io>
This commit is contained in:
parent
743166d284
commit
7737387efe
@ -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
|
||||
|
31
homeassistant/components/rachio/.translations/en.json
Normal file
31
homeassistant/components/rachio/.translations/en.json
Normal file
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -9,21 +9,36 @@ 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
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DOMAIN = "rachio"
|
||||
|
||||
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(
|
||||
@ -39,23 +54,6 @@ CONFIG_SCHEMA = vol.Schema(
|
||||
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"
|
||||
@ -102,28 +100,69 @@ SIGNAL_RACHIO_ZONE_UPDATE = SIGNAL_RACHIO_UPDATE + "_zone"
|
||||
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."""
|
||||
|
||||
# Listen for incoming webhook connections
|
||||
hass.http.register_view(RachioWebhookView())
|
||||
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
|
||||
|
||||
|
||||
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]
|
||||
|
||||
# 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
|
||||
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:
|
||||
@ -132,11 +171,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, 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
|
||||
|
||||
@ -144,12 +187,12 @@ def setup(hass, config) -> bool:
|
||||
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"
|
||||
@ -200,6 +243,8 @@ class RachioIro:
|
||||
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
|
||||
@ -256,6 +301,16 @@ class RachioIro:
|
||||
"""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."""
|
||||
@ -304,10 +359,14 @@ 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)
|
||||
|
||||
async def post(self, request) -> web.Response:
|
||||
"""Handle webhook calls from the server."""
|
||||
hass = request.app["hass"]
|
||||
@ -315,7 +374,7 @@ class RachioWebhookView(HomeAssistantView):
|
||||
|
||||
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)
|
||||
|
||||
|
@ -3,33 +3,41 @@ from abc import abstractmethod
|
||||
import logging
|
||||
|
||||
from homeassistant.components.binary_sensor import BinarySensorDevice
|
||||
from homeassistant.helpers import device_registry
|
||||
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))
|
||||
|
||||
add_entities(devices)
|
||||
devices = await hass.async_add_executor_job(_create_devices, hass, config_entry)
|
||||
async_add_entities(devices)
|
||||
_LOGGER.info("%d Rachio binary sensor(s) added", len(devices))
|
||||
|
||||
|
||||
def _create_devices(hass, config_entry):
|
||||
devices = []
|
||||
for controller in hass.data[DOMAIN_RACHIO][config_entry.entry_id].controllers:
|
||||
devices.append(RachioControllerOnlineBinarySensor(hass, controller))
|
||||
return devices
|
||||
|
||||
|
||||
class RachioControllerBinarySensor(BinarySensorDevice):
|
||||
"""Represent a binary sensor that reflects a Rachio state."""
|
||||
|
||||
@ -70,6 +78,18 @@ class RachioControllerBinarySensor(BinarySensorDevice):
|
||||
"""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.serial_number,)},
|
||||
"connections": {
|
||||
(device_registry.CONNECTION_NETWORK_MAC, 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."""
|
||||
|
127
homeassistant/components/rachio/config_flow.py
Normal file
127
homeassistant/components/rachio/config_flow.py
Normal file
@ -0,0 +1,127 @@
|
||||
"""Config flow for Rachio integration."""
|
||||
import logging
|
||||
|
||||
from rachiopy import Rachio
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import config_entries, core, exceptions
|
||||
from homeassistant.const import CONF_API_KEY
|
||||
from homeassistant.core import callback
|
||||
|
||||
from .const import (
|
||||
CONF_MANUAL_RUN_MINS,
|
||||
DEFAULT_MANUAL_RUN_MINS,
|
||||
KEY_ID,
|
||||
KEY_STATUS,
|
||||
KEY_USERNAME,
|
||||
RACHIO_API_EXCEPTIONS,
|
||||
)
|
||||
from .const import DOMAIN # pylint:disable=unused-import
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DATA_SCHEMA = vol.Schema({vol.Required(CONF_API_KEY): str}, extra=vol.ALLOW_EXTRA)
|
||||
|
||||
|
||||
async def validate_input(hass: core.HomeAssistant, data):
|
||||
"""Validate the user input allows us to connect.
|
||||
|
||||
Data has the keys from DATA_SCHEMA with values provided by the user.
|
||||
"""
|
||||
rachio = Rachio(data[CONF_API_KEY])
|
||||
username = None
|
||||
try:
|
||||
data = await hass.async_add_executor_job(rachio.person.getInfo)
|
||||
_LOGGER.debug("rachio.person.getInfo: %s", data)
|
||||
if int(data[0][KEY_STATUS]) != 200:
|
||||
raise InvalidAuth
|
||||
|
||||
rachio_id = data[1][KEY_ID]
|
||||
data = await hass.async_add_executor_job(rachio.person.get, rachio_id)
|
||||
_LOGGER.debug("rachio.person.get: %s", data)
|
||||
if int(data[0][KEY_STATUS]) != 200:
|
||||
raise CannotConnect
|
||||
|
||||
username = data[1][KEY_USERNAME]
|
||||
# Yes we really do get all these exceptions (hopefully rachiopy switches to requests)
|
||||
except RACHIO_API_EXCEPTIONS as error:
|
||||
_LOGGER.error("Could not reach the Rachio API: %s", error)
|
||||
raise CannotConnect
|
||||
|
||||
# Return info that you want to store in the config entry.
|
||||
return {"title": username}
|
||||
|
||||
|
||||
class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Rachio."""
|
||||
|
||||
VERSION = 1
|
||||
CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_PUSH
|
||||
|
||||
async def async_step_user(self, user_input=None):
|
||||
"""Handle the initial step."""
|
||||
errors = {}
|
||||
_LOGGER.debug("async_step_user: %s", user_input)
|
||||
if user_input is not None:
|
||||
try:
|
||||
info = await validate_input(self.hass, user_input)
|
||||
await self.async_set_unique_id(user_input[CONF_API_KEY])
|
||||
return self.async_create_entry(title=info["title"], data=user_input)
|
||||
except CannotConnect:
|
||||
errors["base"] = "cannot_connect"
|
||||
except InvalidAuth:
|
||||
errors["base"] = "invalid_auth"
|
||||
except Exception: # pylint: disable=broad-except
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user", data_schema=DATA_SCHEMA, errors=errors
|
||||
)
|
||||
|
||||
async def async_step_homekit(self, homekit_info):
|
||||
"""Handle HomeKit discovery."""
|
||||
return await self.async_step_user()
|
||||
|
||||
async def async_step_import(self, user_input):
|
||||
"""Handle import."""
|
||||
return await self.async_step_user(user_input)
|
||||
|
||||
@staticmethod
|
||||
@callback
|
||||
def async_get_options_flow(config_entry):
|
||||
"""Get the options flow for this handler."""
|
||||
return OptionsFlowHandler(config_entry)
|
||||
|
||||
|
||||
class OptionsFlowHandler(config_entries.OptionsFlow):
|
||||
"""Handle a option flow for Rachio."""
|
||||
|
||||
def __init__(self, config_entry: config_entries.ConfigEntry):
|
||||
"""Initialize options flow."""
|
||||
self.config_entry = config_entry
|
||||
|
||||
async def async_step_init(self, user_input=None):
|
||||
"""Handle options flow."""
|
||||
if user_input is not None:
|
||||
return self.async_create_entry(title="", data=user_input)
|
||||
|
||||
data_schema = vol.Schema(
|
||||
{
|
||||
vol.Optional(
|
||||
CONF_MANUAL_RUN_MINS,
|
||||
default=self.config_entry.options.get(
|
||||
CONF_MANUAL_RUN_MINS, DEFAULT_MANUAL_RUN_MINS
|
||||
),
|
||||
): int
|
||||
}
|
||||
)
|
||||
return self.async_show_form(step_id="init", data_schema=data_schema)
|
||||
|
||||
|
||||
class CannotConnect(exceptions.HomeAssistantError):
|
||||
"""Error to indicate we cannot connect."""
|
||||
|
||||
|
||||
class InvalidAuth(exceptions.HomeAssistantError):
|
||||
"""Error to indicate there is invalid auth."""
|
42
homeassistant/components/rachio/const.py
Normal file
42
homeassistant/components/rachio/const.py
Normal file
@ -0,0 +1,42 @@
|
||||
"""Constants for rachio."""
|
||||
|
||||
import http.client
|
||||
import ssl
|
||||
|
||||
DEFAULT_NAME = "Rachio"
|
||||
|
||||
DOMAIN = "rachio"
|
||||
|
||||
CONF_CUSTOM_URL = "hass_url_override"
|
||||
# Manual run length
|
||||
CONF_MANUAL_RUN_MINS = "manual_run_mins"
|
||||
DEFAULT_MANUAL_RUN_MINS = 10
|
||||
|
||||
# Keys used in the API JSON
|
||||
KEY_DEVICE_ID = "deviceId"
|
||||
KEY_IMAGE_URL = "imageUrl"
|
||||
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_SERIAL_NUMBER = "serialNumber"
|
||||
KEY_MAC_ADDRESS = "macAddress"
|
||||
KEY_TYPE = "type"
|
||||
KEY_URL = "url"
|
||||
KEY_USERNAME = "username"
|
||||
KEY_ZONE_ID = "zoneId"
|
||||
KEY_ZONE_NUMBER = "zoneNumber"
|
||||
KEY_ZONES = "zones"
|
||||
|
||||
# Yes we really do get all these exceptions (hopefully rachiopy switches to requests)
|
||||
RACHIO_API_EXCEPTIONS = (
|
||||
http.client.HTTPException,
|
||||
ssl.SSLError,
|
||||
OSError,
|
||||
AssertionError,
|
||||
)
|
@ -2,7 +2,17 @@
|
||||
"domain": "rachio",
|
||||
"name": "Rachio",
|
||||
"documentation": "https://www.home-assistant.io/integrations/rachio",
|
||||
"requirements": ["rachiopy==0.1.3"],
|
||||
"dependencies": ["http"],
|
||||
"codeowners": []
|
||||
"requirements": [
|
||||
"rachiopy==0.1.3"
|
||||
],
|
||||
"dependencies": [
|
||||
"http"
|
||||
],
|
||||
"codeowners": ["@bdraco"],
|
||||
"config_flow": true,
|
||||
"homekit": {
|
||||
"models": [
|
||||
"Rachio"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
31
homeassistant/components/rachio/strings.json
Normal file
31
homeassistant/components/rachio/strings.json
Normal file
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -4,20 +4,10 @@ from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from homeassistant.components.switch import SwitchDevice
|
||||
from homeassistant.helpers import device_registry
|
||||
from homeassistant.helpers.dispatcher import dispatcher_connect
|
||||
|
||||
from . import (
|
||||
CONF_MANUAL_RUN_MINS,
|
||||
DOMAIN as DOMAIN_RACHIO,
|
||||
KEY_DEVICE_ID,
|
||||
KEY_ENABLED,
|
||||
KEY_ID,
|
||||
KEY_NAME,
|
||||
KEY_ON,
|
||||
KEY_SUBTYPE,
|
||||
KEY_SUMMARY,
|
||||
KEY_ZONE_ID,
|
||||
KEY_ZONE_NUMBER,
|
||||
SIGNAL_RACHIO_CONTROLLER_UPDATE,
|
||||
SIGNAL_RACHIO_ZONE_UPDATE,
|
||||
SUBTYPE_SLEEP_MODE_OFF,
|
||||
@ -26,6 +16,22 @@ from . import (
|
||||
SUBTYPE_ZONE_STARTED,
|
||||
SUBTYPE_ZONE_STOPPED,
|
||||
)
|
||||
from .const import (
|
||||
CONF_MANUAL_RUN_MINS,
|
||||
DEFAULT_MANUAL_RUN_MINS,
|
||||
DEFAULT_NAME,
|
||||
DOMAIN as DOMAIN_RACHIO,
|
||||
KEY_DEVICE_ID,
|
||||
KEY_ENABLED,
|
||||
KEY_ID,
|
||||
KEY_IMAGE_URL,
|
||||
KEY_NAME,
|
||||
KEY_ON,
|
||||
KEY_SUBTYPE,
|
||||
KEY_SUMMARY,
|
||||
KEY_ZONE_ID,
|
||||
KEY_ZONE_NUMBER,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@ -33,25 +39,30 @@ ATTR_ZONE_SUMMARY = "Summary"
|
||||
ATTR_ZONE_NUMBER = "Zone number"
|
||||
|
||||
|
||||
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 switches."""
|
||||
manual_run_time = timedelta(
|
||||
minutes=hass.data[DOMAIN_RACHIO].config.get(CONF_MANUAL_RUN_MINS)
|
||||
)
|
||||
_LOGGER.info("Rachio run time is %s", str(manual_run_time))
|
||||
|
||||
# Add all zones from all controllers as switches
|
||||
devices = []
|
||||
for controller in hass.data[DOMAIN_RACHIO].controllers:
|
||||
devices.append(RachioStandbySwitch(hass, controller))
|
||||
|
||||
for zone in controller.list_zones():
|
||||
devices.append(RachioZone(hass, controller, zone, manual_run_time))
|
||||
|
||||
add_entities(devices)
|
||||
devices = await hass.async_add_executor_job(_create_devices, hass, config_entry)
|
||||
async_add_entities(devices)
|
||||
_LOGGER.info("%d Rachio switch(es) added", len(devices))
|
||||
|
||||
|
||||
def _create_devices(hass, config_entry):
|
||||
devices = []
|
||||
person = hass.data[DOMAIN_RACHIO][config_entry.entry_id]
|
||||
# Fetch the schedule once at startup
|
||||
# in order to avoid every zone doing it
|
||||
for controller in person.controllers:
|
||||
devices.append(RachioStandbySwitch(hass, controller))
|
||||
zones = controller.list_zones()
|
||||
current_schedule = controller.current_schedule
|
||||
_LOGGER.debug("Rachio setting up zones: %s", zones)
|
||||
for zone in zones:
|
||||
_LOGGER.debug("Rachio setting up zone: %s", zone)
|
||||
devices.append(RachioZone(hass, person, controller, zone, current_schedule))
|
||||
return devices
|
||||
|
||||
|
||||
class RachioSwitch(SwitchDevice):
|
||||
"""Represent a Rachio state that can be toggled."""
|
||||
|
||||
@ -93,6 +104,18 @@ class RachioSwitch(SwitchDevice):
|
||||
# For this device
|
||||
self._handle_update(args, kwargs)
|
||||
|
||||
@property
|
||||
def device_info(self):
|
||||
"""Return the device_info of the device."""
|
||||
return {
|
||||
"identifiers": {(DOMAIN_RACHIO, self._controller.serial_number,)},
|
||||
"connections": {
|
||||
(device_registry.CONNECTION_NETWORK_MAC, self._controller.mac_address,)
|
||||
},
|
||||
"name": self._controller.name,
|
||||
"manufacturer": DEFAULT_NAME,
|
||||
}
|
||||
|
||||
@abstractmethod
|
||||
def _handle_update(self, *args, **kwargs) -> None:
|
||||
"""Handle incoming webhook data."""
|
||||
@ -153,15 +176,18 @@ class RachioStandbySwitch(RachioSwitch):
|
||||
class RachioZone(RachioSwitch):
|
||||
"""Representation of one zone of sprinklers connected to the Rachio Iro."""
|
||||
|
||||
def __init__(self, hass, controller, data, manual_run_time):
|
||||
def __init__(self, hass, person, controller, data, current_schedule):
|
||||
"""Initialize a new Rachio Zone."""
|
||||
self._id = data[KEY_ID]
|
||||
self._zone_name = data[KEY_NAME]
|
||||
self._zone_number = data[KEY_ZONE_NUMBER]
|
||||
self._zone_enabled = data[KEY_ENABLED]
|
||||
self._manual_run_time = manual_run_time
|
||||
self._entity_picture = data.get(KEY_IMAGE_URL)
|
||||
self._person = person
|
||||
self._summary = str()
|
||||
super().__init__(controller)
|
||||
self._current_schedule = current_schedule
|
||||
super().__init__(controller, poll=False)
|
||||
self._state = self.zone_id == self._current_schedule.get(KEY_ZONE_ID)
|
||||
|
||||
# Listen for all zone updates
|
||||
dispatcher_connect(hass, SIGNAL_RACHIO_ZONE_UPDATE, self._handle_update)
|
||||
@ -195,6 +221,11 @@ class RachioZone(RachioSwitch):
|
||||
"""Return whether the zone is allowed to run."""
|
||||
return self._zone_enabled
|
||||
|
||||
@property
|
||||
def entity_picture(self):
|
||||
"""Return the entity picture to use in the frontend, if any."""
|
||||
return self._entity_picture
|
||||
|
||||
@property
|
||||
def state_attributes(self) -> dict:
|
||||
"""Return the optional state attributes."""
|
||||
@ -206,8 +237,18 @@ class RachioZone(RachioSwitch):
|
||||
self.turn_off()
|
||||
|
||||
# Start this zone
|
||||
self._controller.rachio.zone.start(self.zone_id, self._manual_run_time.seconds)
|
||||
_LOGGER.debug("Watering %s on %s", self.name, self._controller.name)
|
||||
manual_run_time = timedelta(
|
||||
minutes=self._person.config_entry.options.get(
|
||||
CONF_MANUAL_RUN_MINS, DEFAULT_MANUAL_RUN_MINS
|
||||
)
|
||||
)
|
||||
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),
|
||||
)
|
||||
|
||||
def turn_off(self, **kwargs) -> None:
|
||||
"""Stop watering all zones."""
|
||||
@ -215,8 +256,8 @@ class RachioZone(RachioSwitch):
|
||||
|
||||
def _poll_update(self, data=None) -> bool:
|
||||
"""Poll the API to check whether the zone is running."""
|
||||
schedule = self._controller.current_schedule
|
||||
return self.zone_id == schedule.get(KEY_ZONE_ID)
|
||||
self._current_schedule = self._controller.current_schedule
|
||||
return self.zone_id == self._current_schedule.get(KEY_ZONE_ID)
|
||||
|
||||
def _handle_update(self, *args, **kwargs) -> None:
|
||||
"""Handle incoming webhook zone data."""
|
||||
|
@ -80,6 +80,7 @@ FLOWS = [
|
||||
"plex",
|
||||
"point",
|
||||
"ps4",
|
||||
"rachio",
|
||||
"rainmachine",
|
||||
"ring",
|
||||
"samsungtv",
|
||||
|
@ -43,6 +43,7 @@ HOMEKIT = {
|
||||
"LIFX": "lifx",
|
||||
"Netatmo Relay": "netatmo",
|
||||
"Presence": "netatmo",
|
||||
"Rachio": "rachio",
|
||||
"TRADFRI": "tradfri",
|
||||
"Welcome": "netatmo",
|
||||
"Wemo": "wemo"
|
||||
|
@ -613,6 +613,9 @@ pyvizio==0.1.35
|
||||
# homeassistant.components.html5
|
||||
pywebpush==1.9.2
|
||||
|
||||
# homeassistant.components.rachio
|
||||
rachiopy==0.1.3
|
||||
|
||||
# homeassistant.components.rainmachine
|
||||
regenmaschine==1.5.1
|
||||
|
||||
|
1
tests/components/rachio/__init__.py
Normal file
1
tests/components/rachio/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Tests for the Rachio integration."""
|
104
tests/components/rachio/test_config_flow.py
Normal file
104
tests/components/rachio/test_config_flow.py
Normal file
@ -0,0 +1,104 @@
|
||||
"""Test the Rachio config flow."""
|
||||
from asynctest import patch
|
||||
from asynctest.mock import MagicMock
|
||||
|
||||
from homeassistant import config_entries, setup
|
||||
from homeassistant.components.rachio.const import (
|
||||
CONF_CUSTOM_URL,
|
||||
CONF_MANUAL_RUN_MINS,
|
||||
DOMAIN,
|
||||
)
|
||||
from homeassistant.const import CONF_API_KEY
|
||||
|
||||
|
||||
def _mock_rachio_return_value(get=None, getInfo=None):
|
||||
rachio_mock = MagicMock()
|
||||
person_mock = MagicMock()
|
||||
type(person_mock).get = MagicMock(return_value=get)
|
||||
type(person_mock).getInfo = MagicMock(return_value=getInfo)
|
||||
type(rachio_mock).person = person_mock
|
||||
return rachio_mock
|
||||
|
||||
|
||||
async def test_form(hass):
|
||||
"""Test we get the form."""
|
||||
await setup.async_setup_component(hass, "persistent_notification", {})
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
assert result["type"] == "form"
|
||||
assert result["errors"] == {}
|
||||
|
||||
rachio_mock = _mock_rachio_return_value(
|
||||
get=({"status": 200}, {"username": "myusername"}),
|
||||
getInfo=({"status": 200}, {"id": "myid"}),
|
||||
)
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.rachio.config_flow.Rachio", return_value=rachio_mock
|
||||
), patch(
|
||||
"homeassistant.components.rachio.async_setup", return_value=True
|
||||
) as mock_setup, patch(
|
||||
"homeassistant.components.rachio.async_setup_entry", return_value=True,
|
||||
) as mock_setup_entry:
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
CONF_API_KEY: "api_key",
|
||||
CONF_CUSTOM_URL: "http://custom.url",
|
||||
CONF_MANUAL_RUN_MINS: 5,
|
||||
},
|
||||
)
|
||||
|
||||
assert result2["type"] == "create_entry"
|
||||
assert result2["title"] == "myusername"
|
||||
assert result2["data"] == {
|
||||
CONF_API_KEY: "api_key",
|
||||
CONF_CUSTOM_URL: "http://custom.url",
|
||||
CONF_MANUAL_RUN_MINS: 5,
|
||||
}
|
||||
await hass.async_block_till_done()
|
||||
assert len(mock_setup.mock_calls) == 1
|
||||
assert len(mock_setup_entry.mock_calls) == 1
|
||||
|
||||
|
||||
async def test_form_invalid_auth(hass):
|
||||
"""Test we handle invalid auth."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
|
||||
rachio_mock = _mock_rachio_return_value(
|
||||
get=({"status": 200}, {"username": "myusername"}),
|
||||
getInfo=({"status": 412}, {"error": "auth fail"}),
|
||||
)
|
||||
with patch(
|
||||
"homeassistant.components.rachio.config_flow.Rachio", return_value=rachio_mock
|
||||
):
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {CONF_API_KEY: "api_key"},
|
||||
)
|
||||
|
||||
assert result2["type"] == "form"
|
||||
assert result2["errors"] == {"base": "invalid_auth"}
|
||||
|
||||
|
||||
async def test_form_cannot_connect(hass):
|
||||
"""Test we handle cannot connect error."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
|
||||
rachio_mock = _mock_rachio_return_value(
|
||||
get=({"status": 599}, {"username": "myusername"}),
|
||||
getInfo=({"status": 200}, {"id": "myid"}),
|
||||
)
|
||||
with patch(
|
||||
"homeassistant.components.rachio.config_flow.Rachio", return_value=rachio_mock
|
||||
):
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {CONF_API_KEY: "api_key"},
|
||||
)
|
||||
|
||||
assert result2["type"] == "form"
|
||||
assert result2["errors"] == {"base": "cannot_connect"}
|
Loading…
x
Reference in New Issue
Block a user