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