From d539dfffe38d5b3ec805de55c7933a74724941e2 Mon Sep 17 00:00:00 2001 From: Cyr-ius Date: Wed, 25 Mar 2020 13:51:29 +0100 Subject: [PATCH] Add config_flow for roomba --- .../components/roomba/.translations/en.json | 45 ++++++ .../components/roomba/.translations/fr.json | 45 ++++++ homeassistant/components/roomba/__init__.py | 75 +++++++++ .../components/roomba/config_flow.py | 153 ++++++++++++++++++ homeassistant/components/roomba/const.py | 9 ++ homeassistant/components/roomba/manifest.json | 1 + homeassistant/components/roomba/strings.json | 45 ++++++ homeassistant/components/roomba/vacuum.py | 83 +++------- homeassistant/generated/config_flows.py | 1 + requirements_test_all.txt | 3 + tests/components/roomba/__init__.py | 1 + tests/components/roomba/test_config_flow.py | 90 +++++++++++ 12 files changed, 488 insertions(+), 63 deletions(-) create mode 100644 homeassistant/components/roomba/.translations/en.json create mode 100644 homeassistant/components/roomba/.translations/fr.json create mode 100644 homeassistant/components/roomba/config_flow.py create mode 100644 homeassistant/components/roomba/const.py create mode 100644 homeassistant/components/roomba/strings.json create mode 100644 tests/components/roomba/__init__.py create mode 100644 tests/components/roomba/test_config_flow.py diff --git a/homeassistant/components/roomba/.translations/en.json b/homeassistant/components/roomba/.translations/en.json new file mode 100644 index 00000000000..99152734c34 --- /dev/null +++ b/homeassistant/components/roomba/.translations/en.json @@ -0,0 +1,45 @@ +{ + "config": { + "title": "iRobot Roomba", + "step": { + "user": { + "title": "Connect to the device", + "data": { + "host": "Hostname or IP Address", + "username": "Username", + "password": "Password", + "name": "Friendly Name", + "certificate": "Certificate", + "continuous": "Continuous", + "delay": "Delay" + } + } + }, + "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": { + "certificate": "Certificate", + "continuous": "Continuous", + "delay": "Delay" + } + }, + "options": { + "data": { + "certificate": "Certificate", + "continuous": "Continuous", + "delay": "Delay" + } + } + } + } +} diff --git a/homeassistant/components/roomba/.translations/fr.json b/homeassistant/components/roomba/.translations/fr.json new file mode 100644 index 00000000000..fe488be88d0 --- /dev/null +++ b/homeassistant/components/roomba/.translations/fr.json @@ -0,0 +1,45 @@ +{ + "config": { + "title": "iRobot Roomba", + "step": { + "user": { + "title": "Connect to the device", + "data": { + "host": "Nom ou Addresse IP", + "username": "Utilisateur", + "password": "Mot de passe", + "name": "Nom", + "certificate": "Certificat", + "continuous": "Continue", + "delay": "Delais" + } + } + }, + "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": { + "certificate": "Certificat", + "continuous": "Continue", + "delay": "Delais" + } + }, + "options": { + "data": { + "certificate": "Certificate", + "continuous": "Continuous", + "delay": "Delay" + } + } + } + } +} diff --git a/homeassistant/components/roomba/__init__.py b/homeassistant/components/roomba/__init__.py index c0e5f68483e..04d705e9178 100644 --- a/homeassistant/components/roomba/__init__.py +++ b/homeassistant/components/roomba/__init__.py @@ -1 +1,76 @@ """The roomba component.""" +import logging + +import async_timeout +from roomba import Roomba, RoombaConnectionError + +from homeassistant import config_entries +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME + +from .const import CONF_CERT, CONF_CONTINUOUS, CONF_DELAY, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup(hass, config): + """Set up the roomba environment.""" + if DOMAIN not in config: + return True + + if not hass.config_entries.async_entries(DOMAIN): + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data={} + ) + ) + + +async def async_setup_entry(hass, config_entry): + """Set the config entry up.""" + # Set up roomba platforms with config entry + if config_entry.data is None: + return False + + if not config_entry.options: + hass.config_entries.async_update_entry( + config_entry, + options={ + "certificate": config_entry.data[CONF_CERT], + "continuous": config_entry.data[CONF_CONTINUOUS], + "delay": config_entry.data[CONF_DELAY], + }, + ) + + if DOMAIN not in hass.data: + hass.data[DOMAIN] = {} + + if "roomba" not in hass.data[DOMAIN]: + roomba = Roomba( + address=config_entry.data[CONF_HOST], + blid=config_entry.data[CONF_USERNAME], + password=config_entry.data[CONF_PASSWORD], + cert_name=config_entry.data[CONF_CERT], + continuous=config_entry.data[CONF_CONTINUOUS], + delay=config_entry.data[CONF_DELAY], + ) + hass.data[DOMAIN]["roomba"] = roomba + try: + with async_timeout.timeout(9): + await hass.async_add_job(roomba.connect) + except RoombaConnectionError: + _LOGGER.error("Error to connect to {}".format(config_entry.data[CONF_HOST])) + return False + + hass.data[DOMAIN]["name"] = config_entry.data[CONF_NAME] + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(config_entry, "vacuum") + ) + return True + + +async def async_unload_entry(hass, config_entry): + """Unload a config entry.""" + await hass.config_entries.async_forward_entry_unload(config_entry, "vacuum") + roomba = hass.data[DOMAIN]["roomba"] + await hass.async_add_job(roomba.disconnect) + return True diff --git a/homeassistant/components/roomba/config_flow.py b/homeassistant/components/roomba/config_flow.py new file mode 100644 index 00000000000..e30598cf738 --- /dev/null +++ b/homeassistant/components/roomba/config_flow.py @@ -0,0 +1,153 @@ +"""Config flow to configure demo component.""" +import logging +import time + +from roomba import Roomba, RoombaConnectionError +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import callback + +from .const import ( + CONF_CERT, + CONF_CONTINUOUS, + CONF_DELAY, + DEFAULT_CERT, + DEFAULT_CONTINUOUS, + DEFAULT_DELAY, + DEFAULT_NAME, + DOMAIN, +) + +DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): str, + vol.Optional(CONF_CERT, default=DEFAULT_CERT): str, + vol.Optional(CONF_CONTINUOUS, default=DEFAULT_CONTINUOUS): bool, + vol.Optional(CONF_DELAY, default=DEFAULT_DELAY): int, + } +) + +_LOGGER = logging.getLogger(__name__) + + +class RoombaConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Demo configuration flow.""" + + VERSION = 1 + + @staticmethod + @callback + def async_get_options_flow(config_entry): + """Get the options flow for this handler.""" + return OptionsFlowHandler(config_entry) + + async def async_step_import(self, import_info): + """Set the config entry up from yaml.""" + return self.async_create_entry(title="Roomba", data={}) + + async def async_step_user(self, user_input=None): + """Handle a flow initialized by the user.""" + errors = {} + + if DOMAIN not in self.hass.data: + self.hass.data[DOMAIN] = {} + if user_input is not None: + self.host = user_input[CONF_HOST] + self.username = user_input[CONF_USERNAME] + self.password = user_input[CONF_PASSWORD] + self.name = user_input[CONF_NAME] + self.certificate = user_input[CONF_CERT] + self.continuous = user_input[CONF_CONTINUOUS] + self.delay = user_input[CONF_DELAY] + + roomba = Roomba( + address=self.host, + blid=self.username, + password=self.password, + cert_name=self.certificate, + continuous=self.continuous, + delay=self.delay, + ) + _LOGGER.debug("Initializing communication with host %s", self.host) + + try: + await self.hass.async_add_job(roomba.connect) + except RoombaConnectionError: + errors = {"base": "cannot_connect"} + + timeout = time.time() + 1 + while not roomba.roomba_connected and not errors: + if time.time() > timeout: + errors = {"base": "invalid_auth"} + await self.hass.async_add_job(roomba.disconnect) + time.sleep(0.2) + + if roomba.roomba_connected: + self.hass.data[DOMAIN]["roomba"] = roomba + self.hass.data[DOMAIN]["name"] = self.name + return self.async_create_entry( + title=self.name, + data={ + "host": self.host, + "username": self.username, + "password": self.password, + "name": self.name, + "certificate": self.certificate, + "continuous": self.continuous, + "delay": self.delay, + }, + ) + + # If there was no user input, do not show the errors. + if user_input is None: + errors = {} + + return self.async_show_form( + step_id="user", data_schema=DATA_SCHEMA, errors=errors + ) + + +class OptionsFlowHandler(config_entries.OptionsFlow): + """Handle options.""" + + def __init__(self, config_entry): + """Initialize options flow.""" + self.config_entry = config_entry + + async def async_step_init(self, user_input=None): + """Manage the options.""" + return await self.async_step_options() + + async def async_step_options(self, user_input=None): + """Manage the options.""" + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + return self.async_show_form( + step_id="options", + data_schema=vol.Schema( + { + vol.Optional( + CONF_CERT, + default=self.config_entry.options.get(CONF_CERT, DEFAULT_CERT), + ): str, + vol.Optional( + CONF_CONTINUOUS, + default=self.config_entry.options.get( + CONF_CONTINUOUS, DEFAULT_CONTINUOUS + ), + ): bool, + vol.Optional( + CONF_DELAY, + default=self.config_entry.options.get( + CONF_DELAY, DEFAULT_DELAY + ), + ): int, + } + ), + ) diff --git a/homeassistant/components/roomba/const.py b/homeassistant/components/roomba/const.py new file mode 100644 index 00000000000..f5c1a1a3d3e --- /dev/null +++ b/homeassistant/components/roomba/const.py @@ -0,0 +1,9 @@ +"""The roomba constants.""" +DOMAIN = "roomba" +CONF_CERT = "certificate" +CONF_CONTINUOUS = "continuous" +CONF_DELAY = "delay" +DEFAULT_CERT = "/etc/ssl/certs/ca-certificates.crt" +DEFAULT_CONTINUOUS = True +DEFAULT_DELAY = 1 +DEFAULT_NAME = "Roomba" diff --git a/homeassistant/components/roomba/manifest.json b/homeassistant/components/roomba/manifest.json index 942ebd08426..30e975bbb0a 100644 --- a/homeassistant/components/roomba/manifest.json +++ b/homeassistant/components/roomba/manifest.json @@ -1,6 +1,7 @@ { "domain": "roomba", "name": "iRobot Roomba", + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/roomba", "requirements": ["roombapy==1.4.3"], "codeowners": ["@pschmitt"] diff --git a/homeassistant/components/roomba/strings.json b/homeassistant/components/roomba/strings.json new file mode 100644 index 00000000000..99152734c34 --- /dev/null +++ b/homeassistant/components/roomba/strings.json @@ -0,0 +1,45 @@ +{ + "config": { + "title": "iRobot Roomba", + "step": { + "user": { + "title": "Connect to the device", + "data": { + "host": "Hostname or IP Address", + "username": "Username", + "password": "Password", + "name": "Friendly Name", + "certificate": "Certificate", + "continuous": "Continuous", + "delay": "Delay" + } + } + }, + "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": { + "certificate": "Certificate", + "continuous": "Continuous", + "delay": "Delay" + } + }, + "options": { + "data": { + "certificate": "Certificate", + "continuous": "Continuous", + "delay": "Delay" + } + } + } + } +} diff --git a/homeassistant/components/roomba/vacuum.py b/homeassistant/components/roomba/vacuum.py index 172a494b602..5539fc7135c 100644 --- a/homeassistant/components/roomba/vacuum.py +++ b/homeassistant/components/roomba/vacuum.py @@ -1,13 +1,7 @@ """Support for Wi-Fi enabled iRobot Roombas.""" -import asyncio import logging -import async_timeout -from roomba import Roomba -import voluptuous as vol - from homeassistant.components.vacuum import ( - PLATFORM_SCHEMA, SUPPORT_BATTERY, SUPPORT_FAN_SPEED, SUPPORT_LOCATE, @@ -20,9 +14,8 @@ from homeassistant.components.vacuum import ( SUPPORT_TURN_ON, VacuumDevice, ) -from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME -from homeassistant.exceptions import PlatformNotReady -import homeassistant.helpers.config_validation as cv + +from .const import DOMAIN _LOGGER = logging.getLogger(__name__) @@ -37,34 +30,11 @@ ATTR_SOFTWARE_VERSION = "software_version" CAP_POSITION = "position" CAP_CARPET_BOOST = "carpet_boost" -CONF_CERT = "certificate" -CONF_CONTINUOUS = "continuous" -CONF_DELAY = "delay" - -DEFAULT_CERT = "/etc/ssl/certs/ca-certificates.crt" -DEFAULT_CONTINUOUS = True -DEFAULT_DELAY = 1 -DEFAULT_NAME = "Roomba" - -PLATFORM = "roomba" - FAN_SPEED_AUTOMATIC = "Automatic" FAN_SPEED_ECO = "Eco" FAN_SPEED_PERFORMANCE = "Performance" FAN_SPEEDS = [FAN_SPEED_AUTOMATIC, FAN_SPEED_ECO, FAN_SPEED_PERFORMANCE] -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Required(CONF_HOST): cv.string, - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - vol.Optional(CONF_CERT, default=DEFAULT_CERT): cv.string, - vol.Optional(CONF_CONTINUOUS, default=DEFAULT_CONTINUOUS): cv.boolean, - vol.Optional(CONF_DELAY, default=DEFAULT_DELAY): cv.positive_int, - }, - extra=vol.ALLOW_EXTRA, -) # Commonly supported features SUPPORT_ROOMBA = ( @@ -83,39 +53,12 @@ SUPPORT_ROOMBA = ( SUPPORT_ROOMBA_CARPET_BOOST = SUPPORT_ROOMBA | SUPPORT_FAN_SPEED -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Set up the iRobot Roomba vacuum cleaner platform.""" - - if PLATFORM not in hass.data: - hass.data[PLATFORM] = {} - - host = config.get(CONF_HOST) - name = config.get(CONF_NAME) - username = config.get(CONF_USERNAME) - password = config.get(CONF_PASSWORD) - certificate = config.get(CONF_CERT) - continuous = config.get(CONF_CONTINUOUS) - delay = config.get(CONF_DELAY) - - roomba = Roomba( - address=host, - blid=username, - password=password, - cert_name=certificate, - continuous=continuous, - delay=delay, - ) - _LOGGER.debug("Initializing communication with host %s", host) - - try: - with async_timeout.timeout(9): - await hass.async_add_job(roomba.connect) - except asyncio.TimeoutError: - raise PlatformNotReady +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the iRobot Roomba vacuum cleaner.""" + name = hass.data[DOMAIN]["name"] + roomba = hass.data[DOMAIN]["roomba"] roomba_vac = RoombaVacuum(name, roomba) - hass.data[PLATFORM][host] = roomba_vac - async_add_entities([roomba_vac], True) @@ -135,6 +78,20 @@ class RoombaVacuum(VacuumDevice): self.vacuum = roomba self.vacuum_state = None + @property + def unique_id(self): + """Return the uniqueid of the vacuum cleaner.""" + return self._name + + @property + def device_info(self): + """Return the device info of the vacuum cleaner.""" + return { + "identifiers": {(DOMAIN, self.unique_id)}, + "manufacturer": "iRobots", + "name": str(self._name), + } + @property def supported_features(self): """Flag vacuum cleaner robot features that are supported.""" diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index e00cd1b5936..015dc4b7431 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -96,6 +96,7 @@ FLOWS = [ "rainmachine", "ring", "roku", + "roomba", "samsungtv", "sense", "sentry", diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b064ca1b411..e7e587ef93a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -678,6 +678,9 @@ ring_doorbell==0.6.0 # homeassistant.components.roku roku==4.1.0 +# homeassistant.components.roomba +roombapy==1.4.3 + # homeassistant.components.yamaha rxv==0.6.0 diff --git a/tests/components/roomba/__init__.py b/tests/components/roomba/__init__.py new file mode 100644 index 00000000000..a255e21c709 --- /dev/null +++ b/tests/components/roomba/__init__.py @@ -0,0 +1 @@ +"""Tests for the iRobot Roomba integration.""" diff --git a/tests/components/roomba/test_config_flow.py b/tests/components/roomba/test_config_flow.py new file mode 100644 index 00000000000..5ac8ea6149a --- /dev/null +++ b/tests/components/roomba/test_config_flow.py @@ -0,0 +1,90 @@ +"""Test the iRobot Roomba config flow.""" +from asynctest import patch + +from homeassistant import config_entries, setup +from homeassistant.components.roomba.config_flow import CannotConnect, InvalidAuth +from homeassistant.components.roomba.const import DOMAIN + + +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"] == {} + + with patch( + "homeassistant.components.roomba.config_flow.PlaceholderHub.authenticate", + return_value=True, + ), patch( + "homeassistant.components.roomba.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.roomba.async_setup_entry", return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.1", + "username": "test-username", + "password": "test-password", + }, + ) + + assert result2["type"] == "create_entry" + assert result2["title"] == "Name of the device" + assert result2["data"] == { + "host": "1.1.1.1", + "username": "test-username", + "password": "test-password", + } + 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} + ) + + with patch( + "homeassistant.components.roomba.config_flow.PlaceholderHub.authenticate", + side_effect=InvalidAuth, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.1", + "username": "test-username", + "password": "test-password", + }, + ) + + 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} + ) + + with patch( + "homeassistant.components.roomba.config_flow.PlaceholderHub.authenticate", + side_effect=CannotConnect, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.1", + "username": "test-username", + "password": "test-password", + }, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "cannot_connect"}