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:
J. Nick Koston 2020-03-14 00:46:17 -05:00 committed by GitHub
parent 743166d284
commit 7737387efe
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 561 additions and 89 deletions

View File

@ -286,6 +286,7 @@ homeassistant/components/qnap/* @colinodell
homeassistant/components/quantum_gateway/* @cisasteelersfan homeassistant/components/quantum_gateway/* @cisasteelersfan
homeassistant/components/qvr_pro/* @oblogic7 homeassistant/components/qvr_pro/* @oblogic7
homeassistant/components/qwikswitch/* @kellerza homeassistant/components/qwikswitch/* @kellerza
homeassistant/components/rachio/* @bdraco
homeassistant/components/rainbird/* @konikvranik homeassistant/components/rainbird/* @konikvranik
homeassistant/components/raincloud/* @vanstinator homeassistant/components/raincloud/* @vanstinator
homeassistant/components/rainforest_eagle/* @gtdiehl @jcalbert homeassistant/components/rainforest_eagle/* @gtdiehl @jcalbert

View 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."
}
}
}
}
}

View File

@ -9,21 +9,36 @@ from rachiopy import Rachio
import voluptuous as vol import voluptuous as vol
from homeassistant.components.http import HomeAssistantView 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, 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 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__) _LOGGER = logging.getLogger(__name__)
DOMAIN = "rachio"
SUPPORTED_DOMAINS = ["switch", "binary_sensor"] 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( CONFIG_SCHEMA = vol.Schema(
{ {
DOMAIN: vol.Schema( DOMAIN: vol.Schema(
@ -39,23 +54,6 @@ CONFIG_SCHEMA = vol.Schema(
extra=vol.ALLOW_EXTRA, 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_ONLINE = "ONLINE"
STATUS_OFFLINE = "OFFLINE" STATUS_OFFLINE = "OFFLINE"
@ -102,28 +100,69 @@ SIGNAL_RACHIO_ZONE_UPDATE = SIGNAL_RACHIO_UPDATE + "_zone"
SIGNAL_RACHIO_SCHEDULE_UPDATE = SIGNAL_RACHIO_UPDATE + "_schedule" SIGNAL_RACHIO_SCHEDULE_UPDATE = SIGNAL_RACHIO_UPDATE + "_schedule"
def setup(hass, config) -> bool: async def async_setup(hass: HomeAssistant, config: dict):
"""Set up the Rachio component.""" """Set up the rachio component from YAML."""
# Listen for incoming webhook connections conf = config.get(DOMAIN)
hass.http.register_view(RachioWebhookView()) 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 # Configure API
api_key = config[DOMAIN].get(CONF_API_KEY) api_key = config.get(CONF_API_KEY)
rachio = Rachio(api_key) rachio = Rachio(api_key)
# Get the URL of this server # 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 hass_url = hass.config.api.base_url if custom_url is None else custom_url
rachio.webhook_auth = secrets.token_hex() 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 # Get the API user
try: try:
person = RachioPerson(hass, rachio, config[DOMAIN]) person = await hass.async_add_executor_job(RachioPerson, hass, rachio, entry)
except AssertionError as error: # 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) _LOGGER.error("Could not reach the Rachio API: %s", error)
return False raise ConfigEntryNotReady
# Check for Rachio controller devices # Check for Rachio controller devices
if not person.controllers: if not person.controllers:
@ -132,11 +171,15 @@ def setup(hass, config) -> bool:
_LOGGER.info("%d Rachio device(s) found", len(person.controllers)) _LOGGER.info("%d Rachio device(s) found", len(person.controllers))
# Enable component # 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: 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 return True
@ -144,12 +187,12 @@ def setup(hass, config) -> bool:
class RachioPerson: class RachioPerson:
"""Represent a Rachio user.""" """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.""" """Create an object from the provided API instance."""
# Use API token to get user ID # Use API token to get user ID
self._hass = hass self._hass = hass
self.rachio = rachio self.rachio = rachio
self.config = config self.config_entry = config_entry
response = rachio.person.getInfo() response = rachio.person.getInfo()
assert int(response[0][KEY_STATUS]) == 200, "API key error" assert int(response[0][KEY_STATUS]) == 200, "API key error"
@ -200,6 +243,8 @@ class RachioIro:
self.rachio = rachio self.rachio = rachio
self._id = data[KEY_ID] self._id = data[KEY_ID]
self._name = data[KEY_NAME] 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._zones = data[KEY_ZONES]
self._init_data = data self._init_data = data
self._webhooks = webhooks self._webhooks = webhooks
@ -256,6 +301,16 @@ class RachioIro:
"""Return the Rachio API controller ID.""" """Return the Rachio API controller ID."""
return self._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 @property
def name(self) -> str: def name(self) -> str:
"""Return the user-defined name of the controller.""" """Return the user-defined name of the controller."""
@ -304,10 +359,14 @@ class RachioWebhookView(HomeAssistantView):
} }
requires_auth = False # Handled separately 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: async def post(self, request) -> web.Response:
"""Handle webhook calls from the server.""" """Handle webhook calls from the server."""
hass = request.app["hass"] hass = request.app["hass"]
@ -315,7 +374,7 @@ class RachioWebhookView(HomeAssistantView):
try: try:
auth = data.get(KEY_EXTERNAL_ID, str()).split(":")[1] 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): except (AssertionError, IndexError):
return web.Response(status=web.HTTPForbidden.status_code) return web.Response(status=web.HTTPForbidden.status_code)

View File

@ -3,33 +3,41 @@ from abc import abstractmethod
import logging import logging
from homeassistant.components.binary_sensor import BinarySensorDevice from homeassistant.components.binary_sensor import BinarySensorDevice
from homeassistant.helpers import device_registry
from homeassistant.helpers.dispatcher import dispatcher_connect from homeassistant.helpers.dispatcher import dispatcher_connect
from . import ( from . import (
DOMAIN as DOMAIN_RACHIO,
KEY_DEVICE_ID,
KEY_STATUS,
KEY_SUBTYPE,
SIGNAL_RACHIO_CONTROLLER_UPDATE, SIGNAL_RACHIO_CONTROLLER_UPDATE,
STATUS_OFFLINE, STATUS_OFFLINE,
STATUS_ONLINE, STATUS_ONLINE,
SUBTYPE_OFFLINE, SUBTYPE_OFFLINE,
SUBTYPE_ONLINE, SUBTYPE_ONLINE,
) )
from .const import (
DEFAULT_NAME,
DOMAIN as DOMAIN_RACHIO,
KEY_DEVICE_ID,
KEY_STATUS,
KEY_SUBTYPE,
)
_LOGGER = logging.getLogger(__name__) _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.""" """Set up the Rachio binary sensors."""
devices = [] devices = await hass.async_add_executor_job(_create_devices, hass, config_entry)
for controller in hass.data[DOMAIN_RACHIO].controllers: async_add_entities(devices)
devices.append(RachioControllerOnlineBinarySensor(hass, controller))
add_entities(devices)
_LOGGER.info("%d Rachio binary sensor(s) added", len(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): class RachioControllerBinarySensor(BinarySensorDevice):
"""Represent a binary sensor that reflects a Rachio state.""" """Represent a binary sensor that reflects a Rachio state."""
@ -70,6 +78,18 @@ class RachioControllerBinarySensor(BinarySensorDevice):
"""Request the state from the API.""" """Request the state from the API."""
pass 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 @abstractmethod
def _handle_update(self, *args, **kwargs) -> None: def _handle_update(self, *args, **kwargs) -> None:
"""Handle an update to the state of this sensor.""" """Handle an update to the state of this sensor."""

View 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."""

View 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,
)

View File

@ -2,7 +2,17 @@
"domain": "rachio", "domain": "rachio",
"name": "Rachio", "name": "Rachio",
"documentation": "https://www.home-assistant.io/integrations/rachio", "documentation": "https://www.home-assistant.io/integrations/rachio",
"requirements": ["rachiopy==0.1.3"], "requirements": [
"dependencies": ["http"], "rachiopy==0.1.3"
"codeowners": [] ],
"dependencies": [
"http"
],
"codeowners": ["@bdraco"],
"config_flow": true,
"homekit": {
"models": [
"Rachio"
]
}
} }

View 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."
}
}
}
}
}

View File

@ -4,20 +4,10 @@ from datetime import timedelta
import logging import logging
from homeassistant.components.switch import SwitchDevice from homeassistant.components.switch import SwitchDevice
from homeassistant.helpers import device_registry
from homeassistant.helpers.dispatcher import dispatcher_connect from homeassistant.helpers.dispatcher import dispatcher_connect
from . import ( 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_CONTROLLER_UPDATE,
SIGNAL_RACHIO_ZONE_UPDATE, SIGNAL_RACHIO_ZONE_UPDATE,
SUBTYPE_SLEEP_MODE_OFF, SUBTYPE_SLEEP_MODE_OFF,
@ -26,6 +16,22 @@ from . import (
SUBTYPE_ZONE_STARTED, SUBTYPE_ZONE_STARTED,
SUBTYPE_ZONE_STOPPED, 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__) _LOGGER = logging.getLogger(__name__)
@ -33,25 +39,30 @@ ATTR_ZONE_SUMMARY = "Summary"
ATTR_ZONE_NUMBER = "Zone number" 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.""" """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 # Add all zones from all controllers as switches
devices = [] devices = await hass.async_add_executor_job(_create_devices, hass, config_entry)
for controller in hass.data[DOMAIN_RACHIO].controllers: async_add_entities(devices)
devices.append(RachioStandbySwitch(hass, controller))
for zone in controller.list_zones():
devices.append(RachioZone(hass, controller, zone, manual_run_time))
add_entities(devices)
_LOGGER.info("%d Rachio switch(es) added", len(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): class RachioSwitch(SwitchDevice):
"""Represent a Rachio state that can be toggled.""" """Represent a Rachio state that can be toggled."""
@ -93,6 +104,18 @@ class RachioSwitch(SwitchDevice):
# For this device # For this device
self._handle_update(args, kwargs) 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 @abstractmethod
def _handle_update(self, *args, **kwargs) -> None: def _handle_update(self, *args, **kwargs) -> None:
"""Handle incoming webhook data.""" """Handle incoming webhook data."""
@ -153,15 +176,18 @@ class RachioStandbySwitch(RachioSwitch):
class RachioZone(RachioSwitch): class RachioZone(RachioSwitch):
"""Representation of one zone of sprinklers connected to the Rachio Iro.""" """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.""" """Initialize a new Rachio Zone."""
self._id = data[KEY_ID] self._id = data[KEY_ID]
self._zone_name = data[KEY_NAME] self._zone_name = data[KEY_NAME]
self._zone_number = data[KEY_ZONE_NUMBER] self._zone_number = data[KEY_ZONE_NUMBER]
self._zone_enabled = data[KEY_ENABLED] 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() 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 # Listen for all zone updates
dispatcher_connect(hass, SIGNAL_RACHIO_ZONE_UPDATE, self._handle_update) 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 whether the zone is allowed to run."""
return self._zone_enabled 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 @property
def state_attributes(self) -> dict: def state_attributes(self) -> dict:
"""Return the optional state attributes.""" """Return the optional state attributes."""
@ -206,8 +237,18 @@ class RachioZone(RachioSwitch):
self.turn_off() self.turn_off()
# Start this zone # Start this zone
self._controller.rachio.zone.start(self.zone_id, self._manual_run_time.seconds) manual_run_time = timedelta(
_LOGGER.debug("Watering %s on %s", self.name, self._controller.name) 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: def turn_off(self, **kwargs) -> None:
"""Stop watering all zones.""" """Stop watering all zones."""
@ -215,8 +256,8 @@ class RachioZone(RachioSwitch):
def _poll_update(self, data=None) -> bool: def _poll_update(self, data=None) -> bool:
"""Poll the API to check whether the zone is running.""" """Poll the API to check whether the zone is running."""
schedule = self._controller.current_schedule self._current_schedule = self._controller.current_schedule
return self.zone_id == schedule.get(KEY_ZONE_ID) return self.zone_id == self._current_schedule.get(KEY_ZONE_ID)
def _handle_update(self, *args, **kwargs) -> None: def _handle_update(self, *args, **kwargs) -> None:
"""Handle incoming webhook zone data.""" """Handle incoming webhook zone data."""

View File

@ -80,6 +80,7 @@ FLOWS = [
"plex", "plex",
"point", "point",
"ps4", "ps4",
"rachio",
"rainmachine", "rainmachine",
"ring", "ring",
"samsungtv", "samsungtv",

View File

@ -43,6 +43,7 @@ HOMEKIT = {
"LIFX": "lifx", "LIFX": "lifx",
"Netatmo Relay": "netatmo", "Netatmo Relay": "netatmo",
"Presence": "netatmo", "Presence": "netatmo",
"Rachio": "rachio",
"TRADFRI": "tradfri", "TRADFRI": "tradfri",
"Welcome": "netatmo", "Welcome": "netatmo",
"Wemo": "wemo" "Wemo": "wemo"

View File

@ -613,6 +613,9 @@ pyvizio==0.1.35
# homeassistant.components.html5 # homeassistant.components.html5
pywebpush==1.9.2 pywebpush==1.9.2
# homeassistant.components.rachio
rachiopy==0.1.3
# homeassistant.components.rainmachine # homeassistant.components.rainmachine
regenmaschine==1.5.1 regenmaschine==1.5.1

View File

@ -0,0 +1 @@
"""Tests for the Rachio integration."""

View 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"}