From 7737387efe8f592a913fe8c39a6991f9266a0b78 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 14 Mar 2020 00:46:17 -0500 Subject: [PATCH] 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 * 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 --- CODEOWNERS | 1 + .../components/rachio/.translations/en.json | 31 ++++ homeassistant/components/rachio/__init__.py | 147 ++++++++++++------ .../components/rachio/binary_sensor.py | 40 +++-- .../components/rachio/config_flow.py | 127 +++++++++++++++ homeassistant/components/rachio/const.py | 42 +++++ homeassistant/components/rachio/manifest.json | 16 +- homeassistant/components/rachio/strings.json | 31 ++++ homeassistant/components/rachio/switch.py | 105 +++++++++---- homeassistant/generated/config_flows.py | 1 + homeassistant/generated/zeroconf.py | 1 + requirements_test_all.txt | 3 + tests/components/rachio/__init__.py | 1 + tests/components/rachio/test_config_flow.py | 104 +++++++++++++ 14 files changed, 561 insertions(+), 89 deletions(-) create mode 100644 homeassistant/components/rachio/.translations/en.json create mode 100644 homeassistant/components/rachio/config_flow.py create mode 100644 homeassistant/components/rachio/const.py create mode 100644 homeassistant/components/rachio/strings.json create mode 100644 tests/components/rachio/__init__.py create mode 100644 tests/components/rachio/test_config_flow.py diff --git a/CODEOWNERS b/CODEOWNERS index 8b85278b4bb..b0498c8a60b 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -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 diff --git a/homeassistant/components/rachio/.translations/en.json b/homeassistant/components/rachio/.translations/en.json new file mode 100644 index 00000000000..391320289db --- /dev/null +++ b/homeassistant/components/rachio/.translations/en.json @@ -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." + } + } + } + } +} diff --git a/homeassistant/components/rachio/__init__.py b/homeassistant/components/rachio/__init__.py index faa0b9da379..67659c6ee4d 100644 --- a/homeassistant/components/rachio/__init__.py +++ b/homeassistant/components/rachio/__init__.py @@ -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) diff --git a/homeassistant/components/rachio/binary_sensor.py b/homeassistant/components/rachio/binary_sensor.py index f13eba59ac9..31a5cd889e9 100644 --- a/homeassistant/components/rachio/binary_sensor.py +++ b/homeassistant/components/rachio/binary_sensor.py @@ -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.""" diff --git a/homeassistant/components/rachio/config_flow.py b/homeassistant/components/rachio/config_flow.py new file mode 100644 index 00000000000..3d2a4c18ab2 --- /dev/null +++ b/homeassistant/components/rachio/config_flow.py @@ -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.""" diff --git a/homeassistant/components/rachio/const.py b/homeassistant/components/rachio/const.py new file mode 100644 index 00000000000..2388ed283f1 --- /dev/null +++ b/homeassistant/components/rachio/const.py @@ -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, +) diff --git a/homeassistant/components/rachio/manifest.json b/homeassistant/components/rachio/manifest.json index fae640f9262..9b293ee5df2 100644 --- a/homeassistant/components/rachio/manifest.json +++ b/homeassistant/components/rachio/manifest.json @@ -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" + ] + } } diff --git a/homeassistant/components/rachio/strings.json b/homeassistant/components/rachio/strings.json new file mode 100644 index 00000000000..391320289db --- /dev/null +++ b/homeassistant/components/rachio/strings.json @@ -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." + } + } + } + } +} diff --git a/homeassistant/components/rachio/switch.py b/homeassistant/components/rachio/switch.py index a3a4f6bcca1..7f76cd9042d 100644 --- a/homeassistant/components/rachio/switch.py +++ b/homeassistant/components/rachio/switch.py @@ -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.""" diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index a7e9b63c1a5..c19e9fafbc0 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -80,6 +80,7 @@ FLOWS = [ "plex", "point", "ps4", + "rachio", "rainmachine", "ring", "samsungtv", diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 9817dd69f81..1cf88a5c7ae 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -43,6 +43,7 @@ HOMEKIT = { "LIFX": "lifx", "Netatmo Relay": "netatmo", "Presence": "netatmo", + "Rachio": "rachio", "TRADFRI": "tradfri", "Welcome": "netatmo", "Wemo": "wemo" diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3719522358d..7522274f736 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -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 diff --git a/tests/components/rachio/__init__.py b/tests/components/rachio/__init__.py new file mode 100644 index 00000000000..64fdec71144 --- /dev/null +++ b/tests/components/rachio/__init__.py @@ -0,0 +1 @@ +"""Tests for the Rachio integration.""" diff --git a/tests/components/rachio/test_config_flow.py b/tests/components/rachio/test_config_flow.py new file mode 100644 index 00000000000..f5df0817846 --- /dev/null +++ b/tests/components/rachio/test_config_flow.py @@ -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"}