From 74e7f7c879d5041179d2651b6d74fadbb94736d2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 11 Jan 2021 03:46:54 -1000 Subject: [PATCH] Update roomba config flow to walk users through pairing (#45037) * Update roomba config flow to walk users though pairing * Remove YAML support * adjust tests * increase cover * pylint * pylint --- homeassistant/components/roomba/__init__.py | 65 +-- .../components/roomba/config_flow.py | 165 ++++++- homeassistant/components/roomba/manifest.json | 2 +- homeassistant/components/roomba/strings.json | 36 +- .../components/roomba/translations/en.json | 68 ++- tests/components/roomba/test_config_flow.py | 462 +++++++++++++++--- 6 files changed, 624 insertions(+), 174 deletions(-) diff --git a/homeassistant/components/roomba/__init__.py b/homeassistant/components/roomba/__init__.py index be85ec3619f..63deead7307 100644 --- a/homeassistant/components/roomba/__init__.py +++ b/homeassistant/components/roomba/__init__.py @@ -4,24 +4,17 @@ import logging import async_timeout from roombapy import Roomba, RoombaConnectionError -import voluptuous as vol -from homeassistant import config_entries, exceptions +from homeassistant import exceptions from homeassistant.const import CONF_HOST, CONF_PASSWORD -from homeassistant.core import callback -from homeassistant.helpers import config_validation as cv from .const import ( BLID, COMPONENTS, CONF_BLID, - CONF_CERT, CONF_CONTINUOUS, CONF_DELAY, CONF_NAME, - DEFAULT_CERT, - DEFAULT_CONTINUOUS, - DEFAULT_DELAY, DOMAIN, ROOMBA_SESSION, ) @@ -29,54 +22,9 @@ from .const import ( _LOGGER = logging.getLogger(__name__) -def _has_all_unique_bilds(value): - """Validate that each vacuum configured has a unique bild. - - Uniqueness is determined case-independently. - """ - bilds = [device[CONF_BLID] for device in value] - schema = vol.Schema(vol.Unique()) - schema(bilds) - return value - - -DEVICE_SCHEMA = vol.All( - cv.deprecated(CONF_CERT), - vol.Schema( - { - vol.Required(CONF_HOST): str, - vol.Required(CONF_BLID): str, - vol.Required(CONF_PASSWORD): 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, - }, - ), -) - - -CONFIG_SCHEMA = vol.Schema( - {DOMAIN: vol.All(cv.ensure_list, [DEVICE_SCHEMA], _has_all_unique_bilds)}, - extra=vol.ALLOW_EXTRA, -) - - async def async_setup(hass, config): """Set up the roomba environment.""" hass.data.setdefault(DOMAIN, {}) - - if DOMAIN not in config: - return True - for index, conf in enumerate(config[DOMAIN]): - _LOGGER.debug("Importing Roomba #%d - %s", index, conf[CONF_HOST]) - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data=conf, - ) - ) - return True @@ -88,8 +36,8 @@ async def async_setup_entry(hass, config_entry): hass.config_entries.async_update_entry( config_entry, options={ - "continuous": config_entry.data[CONF_CONTINUOUS], - "delay": config_entry.data[CONF_DELAY], + CONF_CONTINUOUS: config_entry.data[CONF_CONTINUOUS], + CONF_DELAY: config_entry.data[CONF_DELAY], }, ) @@ -184,12 +132,5 @@ def roomba_reported_state(roomba): return roomba.master_state.get("state", {}).get("reported", {}) -@callback -def _async_find_matching_config_entry(hass, prefix): - for entry in hass.config_entries.async_entries(DOMAIN): - if entry.unique_id == prefix: - return entry - - class CannotConnect(exceptions.HomeAssistantError): """Error to indicate we cannot connect.""" diff --git a/homeassistant/components/roomba/config_flow.py b/homeassistant/components/roomba/config_flow.py index 166b5992d86..b99f62e8bdc 100644 --- a/homeassistant/components/roomba/config_flow.py +++ b/homeassistant/components/roomba/config_flow.py @@ -1,5 +1,8 @@ """Config flow to configure roomba component.""" + from roombapy import Roomba +from roombapy.discovery import RoombaDiscovery +from roombapy.getpassword import RoombaPassword import voluptuous as vol from homeassistant import config_entries, core @@ -18,15 +21,9 @@ from .const import ( ) from .const import DOMAIN # pylint:disable=unused-import -DATA_SCHEMA = vol.Schema( - { - vol.Required(CONF_HOST): str, - vol.Required(CONF_BLID): str, - vol.Required(CONF_PASSWORD): str, - vol.Optional(CONF_CONTINUOUS, default=DEFAULT_CONTINUOUS): bool, - vol.Optional(CONF_DELAY, default=DEFAULT_DELAY): int, - } -) +DEFAULT_OPTIONS = {CONF_CONTINUOUS: DEFAULT_CONTINUOUS, CONF_DELAY: DEFAULT_DELAY} + +MAX_NUM_DEVICES_TO_DISCOVER = 25 async def validate_input(hass: core.HomeAssistant, data): @@ -57,34 +54,156 @@ class RoombaConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH + def __init__(self): + """Initialize the roomba flow.""" + self.discovered_robots = {} + self.name = None + self.blid = None + self.host = None + @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 await self.async_step_user(import_info) - async def async_step_user(self, user_input=None): """Handle a flow initialized by the user.""" + # This is for backwards compatibility. + return await self.async_step_init(user_input) + + async def async_step_init(self, user_input=None): + """Handle a flow start.""" + # Check if user chooses manual entry + if user_input is not None and not user_input.get(CONF_HOST): + return await self.async_step_manual() + + if ( + user_input is not None + and self.discovered_robots is not None + and user_input[CONF_HOST] in self.discovered_robots + ): + self.host = user_input[CONF_HOST] + device = self.discovered_robots[self.host] + self.blid = device.blid + self.name = device.robot_name + await self.async_set_unique_id(self.blid, raise_on_progress=False) + self._abort_if_unique_id_configured() + return await self.async_step_link() + + already_configured = self._async_current_ids(False) + discovery = _async_get_roomba_discovery() + devices = await self.hass.async_add_executor_job(discovery.get_all) + + if devices: + # Find already configured hosts + self.discovered_robots = { + device.ip: device + for device in devices + if device.blid not in already_configured + } + + if not self.discovered_robots: + return await self.async_step_manual() + + return self.async_show_form( + step_id="init", + data_schema=vol.Schema( + { + vol.Optional("host"): vol.In( + { + **{ + device.ip: f"{device.robot_name} ({device.ip})" + for device in devices + if device.blid not in already_configured + }, + None: "Manually add a Roomba or Braava", + } + ) + } + ), + ) + + async def async_step_manual(self, user_input=None): + """Handle manual device setup.""" + if user_input is None: + return self.async_show_form( + step_id="manual", + data_schema=vol.Schema( + {vol.Required(CONF_HOST): str, vol.Required(CONF_BLID): str} + ), + ) + + if any( + user_input["host"] == entry.data.get("host") + for entry in self._async_current_entries() + ): + return self.async_abort(reason="already_configured") + + self.host = user_input[CONF_HOST] + self.blid = user_input[CONF_BLID] + await self.async_set_unique_id(self.blid, raise_on_progress=False) + self._abort_if_unique_id_configured() + return await self.async_step_link() + + async def async_step_link(self, user_input=None): + """Attempt to link with the Roomba. + + Given a configured host, will ask the user to press the home and target buttons + to connect to the device. + """ + if user_input is None: + return self.async_show_form(step_id="link") + + password = await self.hass.async_add_executor_job( + RoombaPassword(self.host).get_password + ) + + if not password: + return await self.async_step_link_manual() + + config = { + CONF_HOST: self.host, + CONF_BLID: self.blid, + CONF_PASSWORD: password, + **DEFAULT_OPTIONS, + } + + if not self.name: + try: + info = await validate_input(self.hass, config) + except CannotConnect: + return self.async_abort(reason="cannot_connect") + + await async_disconnect_or_timeout(self.hass, info[ROOMBA_SESSION]) + self.name = info[CONF_NAME] + + return self.async_create_entry(title=self.name, data=config) + + async def async_step_link_manual(self, user_input=None): + """Handle manual linking.""" errors = {} if user_input is not None: - await self.async_set_unique_id(user_input[CONF_BLID]) - self._abort_if_unique_id_configured() + config = { + CONF_HOST: self.host, + CONF_BLID: self.blid, + CONF_PASSWORD: user_input[CONF_PASSWORD], + **DEFAULT_OPTIONS, + } try: - info = await validate_input(self.hass, user_input) + info = await validate_input(self.hass, config) except CannotConnect: errors = {"base": "cannot_connect"} - if "base" not in errors: + if not errors: await async_disconnect_or_timeout(self.hass, info[ROOMBA_SESSION]) - return self.async_create_entry(title=info[CONF_NAME], data=user_input) + return self.async_create_entry(title=info[CONF_NAME], data=config) return self.async_show_form( - step_id="user", data_schema=DATA_SCHEMA, errors=errors + step_id="link_manual", + data_schema=vol.Schema({vol.Required(CONF_PASSWORD): str}), + errors=errors, ) @@ -119,3 +238,11 @@ class OptionsFlowHandler(config_entries.OptionsFlow): } ), ) + + +@callback +def _async_get_roomba_discovery(): + """Create a discovery object.""" + discovery = RoombaDiscovery() + discovery.amount_of_broadcasted_messages = MAX_NUM_DEVICES_TO_DISCOVER + return discovery diff --git a/homeassistant/components/roomba/manifest.json b/homeassistant/components/roomba/manifest.json index 808c7eb9432..f2e5c8035aa 100644 --- a/homeassistant/components/roomba/manifest.json +++ b/homeassistant/components/roomba/manifest.json @@ -1,6 +1,6 @@ { "domain": "roomba", - "name": "iRobot Roomba", + "name": "iRobot Roomba and Braava", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/roomba", "requirements": ["roombapy==1.6.2"], diff --git a/homeassistant/components/roomba/strings.json b/homeassistant/components/roomba/strings.json index cbe7c06ae36..4d0b396d2a9 100644 --- a/homeassistant/components/roomba/strings.json +++ b/homeassistant/components/roomba/strings.json @@ -1,21 +1,39 @@ { "config": { "step": { - "user": { - "title": "Connect to the device", - "description": "Currently retrieving the BLID and password is a manual process. Please follow the steps outlined in the documentation at: https://www.home-assistant.io/integrations/roomba/#retrieving-your-credentials", + "init": { + "title": "Automaticlly connect to the device", + "description": "Select a Roomba or Braava.", + "data": { + "host": "[%key:common::config_flow::data::host%]" + } + }, + "manual": { + "title": "Manually connect to the device", + "description": "No Roomba or Braava have been discovered on your network. The BLID is the portion of the device hostname after `iRobot-`. Please follow the steps outlined in the documentation at: https://www.home-assistant.io/integrations/roomba/#retrieving-your-credentials", "data": { "host": "[%key:common::config_flow::data::host%]", - "blid": "BLID", - "password": "[%key:common::config_flow::data::password%]", - "continuous": "Continuous", - "delay": "Delay" + "blid": "BLID" } - } + }, + "link": { + "title": "Retrieve Password", + "description": "Press and hold the Home button until the device generates a sound (about two seconds)." + }, + "link_manual": { + "title": "Enter Password", + "description": "The password could not be retrivied from the device automaticlly. Please follow the steps outlined in the documentation at: https://www.home-assistant.io/integrations/roomba/#retrieving-your-credentials", + "data": { + "password": "[%key:common::config_flow::data::password%]" + } + } }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" - } + }, + "abort": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + } }, "options": { "step": { diff --git a/homeassistant/components/roomba/translations/en.json b/homeassistant/components/roomba/translations/en.json index b6222f2adf8..4d0b396d2a9 100644 --- a/homeassistant/components/roomba/translations/en.json +++ b/homeassistant/components/roomba/translations/en.json @@ -1,30 +1,48 @@ { - "config": { - "error": { - "cannot_connect": "Failed to connect" - }, - "step": { - "user": { - "data": { - "blid": "BLID", - "continuous": "Continuous", - "delay": "Delay", - "host": "Host", - "password": "Password" - }, - "description": "Currently retrieving the BLID and password is a manual process. Please follow the steps outlined in the documentation at: https://www.home-assistant.io/integrations/roomba/#retrieving-your-credentials", - "title": "Connect to the device" - } + "config": { + "step": { + "init": { + "title": "Automaticlly connect to the device", + "description": "Select a Roomba or Braava.", + "data": { + "host": "[%key:common::config_flow::data::host%]" } + }, + "manual": { + "title": "Manually connect to the device", + "description": "No Roomba or Braava have been discovered on your network. The BLID is the portion of the device hostname after `iRobot-`. Please follow the steps outlined in the documentation at: https://www.home-assistant.io/integrations/roomba/#retrieving-your-credentials", + "data": { + "host": "[%key:common::config_flow::data::host%]", + "blid": "BLID" + } + }, + "link": { + "title": "Retrieve Password", + "description": "Press and hold the Home button until the device generates a sound (about two seconds)." + }, + "link_manual": { + "title": "Enter Password", + "description": "The password could not be retrivied from the device automaticlly. Please follow the steps outlined in the documentation at: https://www.home-assistant.io/integrations/roomba/#retrieving-your-credentials", + "data": { + "password": "[%key:common::config_flow::data::password%]" + } + } }, - "options": { - "step": { - "init": { - "data": { - "continuous": "Continuous", - "delay": "Delay" - } - } + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + }, + "abort": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + } + }, + "options": { + "step": { + "init": { + "data": { + "continuous": "Continuous", + "delay": "Delay" } + } } -} \ No newline at end of file + } +} diff --git a/tests/components/roomba/test_config_flow.py b/tests/components/roomba/test_config_flow.py index 54ef229ec49..a90cc9c621f 100644 --- a/tests/components/roomba/test_config_flow.py +++ b/tests/components/roomba/test_config_flow.py @@ -2,6 +2,7 @@ from unittest.mock import MagicMock, PropertyMock, patch from roombapy import RoombaConnectionError +from roombapy.roomba import RoombaInfo from homeassistant import config_entries, data_entry_flow, setup from homeassistant.components.roomba.const import ( @@ -14,16 +15,9 @@ from homeassistant.const import CONF_HOST, CONF_PASSWORD from tests.common import MockConfigEntry +MOCK_IP = "1.2.3.4" VALID_CONFIG = {CONF_HOST: "1.2.3.4", CONF_BLID: "blid", CONF_PASSWORD: "password"} -VALID_YAML_CONFIG = { - CONF_HOST: "1.2.3.4", - CONF_BLID: "blid", - CONF_PASSWORD: "password", - CONF_CONTINUOUS: True, - CONF_DELAY: 1, -} - def _create_mocked_roomba( roomba_connected=None, master_state=None, connect=None, disconnect=None @@ -36,55 +30,227 @@ def _create_mocked_roomba( return mocked_roomba -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} +def _mocked_discovery(*_): + roomba_discovery = MagicMock() + + roomba = RoombaInfo( + hostname="iRobot-blid", + robot_name="robot_name", + ip=MOCK_IP, + mac="mac", + firmware="firmware", + sku="sku", + capabilities="capabilities", ) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["errors"] == {} + + roomba_discovery.get_all = MagicMock(return_value=[roomba]) + return roomba_discovery + + +def _mocked_failed_discovery(*_): + roomba_discovery = MagicMock() + roomba_discovery.get_all = MagicMock(return_value=[]) + return roomba_discovery + + +def _mocked_getpassword(*_): + roomba_password = MagicMock() + roomba_password.get_password = MagicMock(return_value="password") + return roomba_password + + +def _mocked_failed_getpassword(*_): + roomba_password = MagicMock() + roomba_password.get_password = MagicMock(return_value=None) + return roomba_password + + +async def test_form_user_discovery_and_password_fetch(hass): + """Test we can discovery and fetch the password.""" + await setup.async_setup_component(hass, "persistent_notification", {}) mocked_roomba = _create_mocked_roomba( roomba_connected=True, master_state={"state": {"reported": {"name": "myroomba"}}}, ) + with patch( + "homeassistant.components.roomba.config_flow.RoombaDiscovery", _mocked_discovery + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] is None + assert result["step_id"] == "init" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: MOCK_IP}, + ) + await hass.async_block_till_done() + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["errors"] is None + assert result2["step_id"] == "link" + with patch( "homeassistant.components.roomba.config_flow.Roomba", return_value=mocked_roomba, + ), patch( + "homeassistant.components.roomba.config_flow.RoombaPassword", + _mocked_getpassword, ), 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"], - VALID_CONFIG, + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + {}, ) await hass.async_block_till_done() - assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result2["title"] == "myroomba" - - assert result2["result"].unique_id == "blid" - assert result2["data"] == { + assert result3["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result3["title"] == "robot_name" + assert result3["result"].unique_id == "blid" + assert result3["data"] == { CONF_BLID: "blid", CONF_CONTINUOUS: True, CONF_DELAY: 1, - CONF_HOST: "1.2.3.4", + CONF_HOST: MOCK_IP, CONF_PASSWORD: "password", } assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 -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} +async def test_form_user_discovery_skips_known(hass): + """Test discovery proceeds to manual if all discovered are already known.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + + entry = MockConfigEntry(domain=DOMAIN, data=VALID_CONFIG, unique_id="blid") + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.roomba.config_flow.RoombaDiscovery", _mocked_discovery + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] is None + assert result["step_id"] == "manual" + + +async def test_form_user_failed_discovery_aborts_already_configured(hass): + """Test if we manually configure an existing host we abort.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + + entry = MockConfigEntry(domain=DOMAIN, data=VALID_CONFIG, unique_id="blid") + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.roomba.config_flow.RoombaDiscovery", + _mocked_failed_discovery, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] is None + assert result["step_id"] == "manual" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: MOCK_IP, CONF_BLID: "blid"}, ) + await hass.async_block_till_done() + assert result2["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result2["reason"] == "already_configured" + + +async def test_form_user_discovery_manual_and_auto_password_fetch(hass): + """Test discovery skipped and we can auto fetch the password.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + + mocked_roomba = _create_mocked_roomba( + roomba_connected=True, + master_state={"state": {"reported": {"name": "myroomba"}}}, + ) + + with patch( + "homeassistant.components.roomba.config_flow.RoombaDiscovery", _mocked_discovery + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] is None + assert result["step_id"] == "init" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: None}, + ) + await hass.async_block_till_done() + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["errors"] is None + assert result2["step_id"] == "manual" + + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + {CONF_HOST: MOCK_IP, CONF_BLID: "blid"}, + ) + await hass.async_block_till_done() + assert result3["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result3["errors"] is None + + with patch( + "homeassistant.components.roomba.config_flow.Roomba", + return_value=mocked_roomba, + ), patch( + "homeassistant.components.roomba.config_flow.RoombaPassword", + _mocked_getpassword, + ), 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: + result4 = await hass.config_entries.flow.async_configure( + result3["flow_id"], + {}, + ) + await hass.async_block_till_done() + + assert result4["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result4["title"] == "myroomba" + assert result4["result"].unique_id == "blid" + assert result4["data"] == { + CONF_BLID: "blid", + CONF_CONTINUOUS: True, + CONF_DELAY: 1, + CONF_HOST: MOCK_IP, + CONF_PASSWORD: "password", + } + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_user_discovery_manual_and_auto_password_fetch_but_cannot_connect( + hass, +): + """Test discovery skipped and we can auto fetch the password then we fail to connect.""" + await setup.async_setup_component(hass, "persistent_notification", {}) mocked_roomba = _create_mocked_roomba( connect=RoombaConnectionError, @@ -92,27 +258,161 @@ async def test_form_cannot_connect(hass): master_state={"state": {"reported": {"name": "myroomba"}}}, ) + with patch( + "homeassistant.components.roomba.config_flow.RoombaDiscovery", _mocked_discovery + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] is None + assert result["step_id"] == "init" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: None}, + ) + await hass.async_block_till_done() + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["errors"] is None + assert result2["step_id"] == "manual" + + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + {CONF_HOST: MOCK_IP, CONF_BLID: "blid"}, + ) + await hass.async_block_till_done() + assert result3["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result3["errors"] is None + with patch( "homeassistant.components.roomba.config_flow.Roomba", return_value=mocked_roomba, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - VALID_CONFIG, + ), patch( + "homeassistant.components.roomba.config_flow.RoombaPassword", + _mocked_getpassword, + ), 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: + result4 = await hass.config_entries.flow.async_configure( + result3["flow_id"], + {}, ) + await hass.async_block_till_done() - assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result2["errors"] == {"base": "cannot_connect"} + assert result4["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result4["reason"] == "cannot_connect" + assert len(mock_setup.mock_calls) == 0 + assert len(mock_setup_entry.mock_calls) == 0 -async def test_form_import(hass): - """Test we can import yaml config.""" +async def test_form_user_discovery_fails_and_auto_password_fetch(hass): + """Test discovery fails and we can auto fetch the password.""" + await setup.async_setup_component(hass, "persistent_notification", {}) mocked_roomba = _create_mocked_roomba( roomba_connected=True, - master_state={"state": {"reported": {"name": "imported_roomba"}}}, + master_state={"state": {"reported": {"name": "myroomba"}}}, ) + with patch( + "homeassistant.components.roomba.config_flow.RoombaDiscovery", + _mocked_failed_discovery, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] is None + assert result["step_id"] == "manual" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: MOCK_IP, CONF_BLID: "blid"}, + ) + await hass.async_block_till_done() + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["errors"] is None + + with patch( + "homeassistant.components.roomba.config_flow.Roomba", + return_value=mocked_roomba, + ), patch( + "homeassistant.components.roomba.config_flow.RoombaPassword", + _mocked_getpassword, + ), 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: + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + {}, + ) + await hass.async_block_till_done() + + assert result3["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result3["title"] == "myroomba" + assert result3["result"].unique_id == "blid" + assert result3["data"] == { + CONF_BLID: "blid", + CONF_CONTINUOUS: True, + CONF_DELAY: 1, + CONF_HOST: MOCK_IP, + CONF_PASSWORD: "password", + } + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_user_discovery_fails_and_password_fetch_fails(hass): + """Test discovery fails and password fetch fails.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + + mocked_roomba = _create_mocked_roomba( + roomba_connected=True, + master_state={"state": {"reported": {"name": "myroomba"}}}, + ) + + with patch( + "homeassistant.components.roomba.config_flow.RoombaDiscovery", + _mocked_failed_discovery, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] is None + assert result["step_id"] == "manual" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: MOCK_IP, CONF_BLID: "blid"}, + ) + await hass.async_block_till_done() + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["errors"] is None + + with patch( + "homeassistant.components.roomba.config_flow.RoombaPassword", + _mocked_failed_getpassword, + ): + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + {}, + ) + await hass.async_block_till_done() + with patch( "homeassistant.components.roomba.config_flow.Roomba", return_value=mocked_roomba, @@ -122,39 +422,85 @@ async def test_form_import(hass): "homeassistant.components.roomba.async_setup_entry", return_value=True, ) as mock_setup_entry: - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data=VALID_YAML_CONFIG.copy(), + result4 = await hass.config_entries.flow.async_configure( + result3["flow_id"], + {CONF_PASSWORD: "password"}, ) await hass.async_block_till_done() - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["result"].unique_id == "blid" - assert result["title"] == "imported_roomba" - assert result["data"] == { + assert result4["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result4["title"] == "myroomba" + assert result4["result"].unique_id == "blid" + assert result4["data"] == { CONF_BLID: "blid", CONF_CONTINUOUS: True, CONF_DELAY: 1, - CONF_HOST: "1.2.3.4", + CONF_HOST: MOCK_IP, CONF_PASSWORD: "password", } - assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 -async def test_form_import_dupe(hass): - """Test we get abort on duplicate import.""" +async def test_form_user_discovery_fails_and_password_fetch_fails_and_cannot_connect( + hass, +): + """Test discovery fails and password fetch fails then we cannot connect.""" await setup.async_setup_component(hass, "persistent_notification", {}) - entry = MockConfigEntry(domain=DOMAIN, data=VALID_CONFIG, unique_id="blid") - entry.add_to_hass(hass) - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data=VALID_YAML_CONFIG.copy(), + mocked_roomba = _create_mocked_roomba( + connect=RoombaConnectionError, + roomba_connected=True, + master_state={"state": {"reported": {"name": "myroomba"}}}, ) - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT - assert result["reason"] == "already_configured" + + with patch( + "homeassistant.components.roomba.config_flow.RoombaDiscovery", + _mocked_failed_discovery, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] is None + assert result["step_id"] == "manual" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: MOCK_IP, CONF_BLID: "blid"}, + ) + await hass.async_block_till_done() + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["errors"] is None + + with patch( + "homeassistant.components.roomba.config_flow.RoombaPassword", + _mocked_failed_getpassword, + ): + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + {}, + ) + await hass.async_block_till_done() + + with patch( + "homeassistant.components.roomba.config_flow.Roomba", + return_value=mocked_roomba, + ), 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: + result4 = await hass.config_entries.flow.async_configure( + result3["flow_id"], + {CONF_PASSWORD: "password"}, + ) + await hass.async_block_till_done() + + assert result4["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result4["errors"] == {"base": "cannot_connect"} + assert len(mock_setup.mock_calls) == 0 + assert len(mock_setup_entry.mock_calls) == 0