diff --git a/homeassistant/components/bond/config_flow.py b/homeassistant/components/bond/config_flow.py index c4fb7310b88..aa22dc628da 100644 --- a/homeassistant/components/bond/config_flow.py +++ b/homeassistant/components/bond/config_flow.py @@ -1,23 +1,26 @@ """Config flow for Bond integration.""" import logging +from typing import Any, Dict, Optional from aiohttp import ClientConnectionError, ClientResponseError from bond_api import Bond import voluptuous as vol from homeassistant import config_entries, exceptions -from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST, CONF_NAME +from .const import CONF_BOND_ID from .const import DOMAIN # pylint:disable=unused-import _LOGGER = logging.getLogger(__name__) -DATA_SCHEMA = vol.Schema( +DATA_SCHEMA_USER = vol.Schema( {vol.Required(CONF_HOST): str, vol.Required(CONF_ACCESS_TOKEN): str} ) +DATA_SCHEMA_DISCOVERY = vol.Schema({vol.Required(CONF_ACCESS_TOKEN): str}) -async def validate_input(data) -> str: +async def _validate_input(data: Dict[str, Any]) -> str: """Validate the user input allows us to connect.""" try: @@ -26,11 +29,14 @@ async def validate_input(data) -> str: # call to non-version API is needed to validate authentication await bond.devices() except ClientConnectionError: - raise CannotConnect + raise InputValidationError("cannot_connect") except ClientResponseError as error: if error.status == 401: - raise InvalidAuth - raise + raise InputValidationError("invalid_auth") + raise InputValidationError("unknown") + except Exception: + _LOGGER.exception("Unexpected exception") + raise InputValidationError("unknown") # Return unique ID from the hub to be stored in the config entry. return version["bondid"] @@ -42,32 +48,73 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL - async def async_step_user(self, user_input=None): - """Handle the initial step.""" + _discovered: dict = None + + async def async_step_zeroconf( + self, discovery_info: Optional[Dict[str, Any]] = None + ) -> Dict[str, Any]: + """Handle a flow initialized by zeroconf discovery.""" + name: str = discovery_info[CONF_NAME] + host: str = discovery_info[CONF_HOST] + bond_id = name.partition(".")[0] + await self.async_set_unique_id(bond_id) + self._abort_if_unique_id_configured({CONF_HOST: host}) + + self._discovered = { + CONF_HOST: host, + CONF_BOND_ID: bond_id, + } + # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 + self.context.update({"title_placeholders": self._discovered}) + + return await self.async_step_confirm() + + async def async_step_confirm( + self, user_input: Dict[str, Any] = None + ) -> Dict[str, Any]: + """Handle confirmation flow for discovered bond hub.""" + errors = {} + if user_input is not None: + data = user_input.copy() + data[CONF_HOST] = self._discovered[CONF_HOST] + try: + return await self._try_create_entry(data) + except InputValidationError as error: + errors["base"] = error.base + + return self.async_show_form( + step_id="confirm", + data_schema=DATA_SCHEMA_DISCOVERY, + errors=errors, + description_placeholders=self._discovered, + ) + + async def async_step_user( + self, user_input: Dict[str, Any] = None + ) -> Dict[str, Any]: + """Handle a flow initialized by the user.""" errors = {} if user_input is not None: try: - bond_id = await validate_input(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" - else: - await self.async_set_unique_id(bond_id) - self._abort_if_unique_id_configured() - return self.async_create_entry(title=bond_id, data=user_input) + return await self._try_create_entry(user_input) + except InputValidationError as error: + errors["base"] = error.base return self.async_show_form( - step_id="user", data_schema=DATA_SCHEMA, errors=errors + step_id="user", data_schema=DATA_SCHEMA_USER, errors=errors ) - -class CannotConnect(exceptions.HomeAssistantError): - """Error to indicate we cannot connect.""" + async def _try_create_entry(self, data: Dict[str, Any]) -> Dict[str, Any]: + bond_id = await _validate_input(data) + await self.async_set_unique_id(bond_id) + self._abort_if_unique_id_configured() + return self.async_create_entry(title=bond_id, data=data) -class InvalidAuth(exceptions.HomeAssistantError): - """Error to indicate there is invalid auth.""" +class InputValidationError(exceptions.HomeAssistantError): + """Error to indicate we cannot proceed due to invalid input.""" + + def __init__(self, base: str): + """Initialize with error base.""" + super().__init__() + self.base = base diff --git a/homeassistant/components/bond/const.py b/homeassistant/components/bond/const.py index 4ad08991b31..843c3f9f1dc 100644 --- a/homeassistant/components/bond/const.py +++ b/homeassistant/components/bond/const.py @@ -1,3 +1,5 @@ """Constants for the Bond integration.""" DOMAIN = "bond" + +CONF_BOND_ID: str = "bond_id" diff --git a/homeassistant/components/bond/manifest.json b/homeassistant/components/bond/manifest.json index c03da96cf4e..3f62403dba7 100644 --- a/homeassistant/components/bond/manifest.json +++ b/homeassistant/components/bond/manifest.json @@ -4,6 +4,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/bond", "requirements": ["bond-api==0.1.8"], + "zeroconf": ["_bond._tcp.local."], "codeowners": ["@prystupa"], "quality_scale": "platinum" } diff --git a/homeassistant/components/bond/strings.json b/homeassistant/components/bond/strings.json index 6577c99456c..ba59a61d58d 100644 --- a/homeassistant/components/bond/strings.json +++ b/homeassistant/components/bond/strings.json @@ -1,6 +1,13 @@ { "config": { + "flow_title": "Bond: {bond_id} ({host})", "step": { + "confirm": { + "description": "Do you want to set up {bond_id}?", + "data": { + "access_token": "[%key:common::config_flow::data::access_token%]" + } + }, "user": { "data": { "host": "[%key:common::config_flow::data::host%]", diff --git a/homeassistant/components/bond/translations/en.json b/homeassistant/components/bond/translations/en.json index f26c34aa917..6d47cc35c14 100644 --- a/homeassistant/components/bond/translations/en.json +++ b/homeassistant/components/bond/translations/en.json @@ -8,7 +8,14 @@ "invalid_auth": "Invalid authentication", "unknown": "Unexpected error" }, + "flow_title": "Bond: {bond_id} ({host})", "step": { + "confirm": { + "data": { + "access_token": "Access Token" + }, + "description": "Do you want to set up {bond_id}?" + }, "user": { "data": { "access_token": "Access Token", @@ -17,4 +24,4 @@ } } } -} \ No newline at end of file +} diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 872b07f5c6a..a61444a42c0 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -16,6 +16,9 @@ ZEROCONF = { "axis", "doorbird" ], + "_bond._tcp.local.": [ + "bond" + ], "_daap._tcp.local.": [ "forked_daapd" ], diff --git a/tests/components/bond/test_config_flow.py b/tests/components/bond/test_config_flow.py index fa20355f356..bd499b8ce61 100644 --- a/tests/components/bond/test_config_flow.py +++ b/tests/components/bond/test_config_flow.py @@ -1,4 +1,5 @@ """Test the Bond config flow.""" +from typing import Any, Dict from aiohttp import ClientConnectionError, ClientResponseError @@ -12,8 +13,8 @@ from tests.async_mock import Mock, patch from tests.common import MockConfigEntry -async def test_form(hass: core.HomeAssistant): - """Test we get the form.""" +async def test_user_form(hass: core.HomeAssistant): + """Test we get the user initiated form.""" await setup.async_setup_component(hass, "persistent_notification", {}) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -23,11 +24,7 @@ async def test_form(hass: core.HomeAssistant): with patch_bond_version( return_value={"bondid": "test-bond-id"} - ), patch_bond_device_ids(), patch( - "homeassistant.components.bond.async_setup", return_value=True - ) as mock_setup, patch( - "homeassistant.components.bond.async_setup_entry", return_value=True, - ) as mock_setup_entry: + ), patch_bond_device_ids(), _patch_async_setup() as mock_setup, _patch_async_setup_entry() as mock_setup_entry: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_HOST: "some host", CONF_ACCESS_TOKEN: "test-token"}, @@ -44,7 +41,7 @@ async def test_form(hass: core.HomeAssistant): assert len(mock_setup_entry.mock_calls) == 1 -async def test_form_invalid_auth(hass: core.HomeAssistant): +async def test_user_form_invalid_auth(hass: core.HomeAssistant): """Test we handle invalid auth.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -64,7 +61,7 @@ async def test_form_invalid_auth(hass: core.HomeAssistant): assert result2["errors"] == {"base": "invalid_auth"} -async def test_form_cannot_connect(hass: core.HomeAssistant): +async def test_user_form_cannot_connect(hass: core.HomeAssistant): """Test we handle cannot connect error.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -82,27 +79,27 @@ async def test_form_cannot_connect(hass: core.HomeAssistant): assert result2["errors"] == {"base": "cannot_connect"} -async def test_form_unexpected_error(hass: core.HomeAssistant): - """Test we handle unexpected error gracefully.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} +async def test_user_form_unexpected_client_error(hass: core.HomeAssistant): + """Test we handle unexpected client error gracefully.""" + await _help_test_form_unexpected_error( + hass, + source=config_entries.SOURCE_USER, + user_input={CONF_HOST: "some host", CONF_ACCESS_TOKEN: "test-token"}, + error=ClientResponseError(Mock(), Mock(), status=500), ) - with patch_bond_version( - return_value={"bond_id": "test-bond-id"} - ), patch_bond_device_ids( - side_effect=ClientResponseError(Mock(), Mock(), status=500) - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_HOST: "some host", CONF_ACCESS_TOKEN: "test-token"}, - ) - assert result2["type"] == "form" - assert result2["errors"] == {"base": "unknown"} +async def test_user_form_unexpected_error(hass: core.HomeAssistant): + """Test we handle unexpected error gracefully.""" + await _help_test_form_unexpected_error( + hass, + source=config_entries.SOURCE_USER, + user_input={CONF_HOST: "some host", CONF_ACCESS_TOKEN: "test-token"}, + error=Exception(), + ) -async def test_form_one_entry_per_device_allowed(hass: core.HomeAssistant): +async def test_user_form_one_entry_per_device_allowed(hass: core.HomeAssistant): """Test that only one entry allowed per unique ID reported by Bond hub device.""" MockConfigEntry( domain=DOMAIN, @@ -118,11 +115,7 @@ async def test_form_one_entry_per_device_allowed(hass: core.HomeAssistant): with patch_bond_version( return_value={"bondid": "already-registered-bond-id"} - ), patch_bond_device_ids(), patch( - "homeassistant.components.bond.async_setup", return_value=True - ) as mock_setup, patch( - "homeassistant.components.bond.async_setup_entry", return_value=True, - ) as mock_setup_entry: + ), patch_bond_device_ids(), _patch_async_setup() as mock_setup, _patch_async_setup_entry() as mock_setup_entry: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_HOST: "some host", CONF_ACCESS_TOKEN: "test-token"}, @@ -134,3 +127,108 @@ async def test_form_one_entry_per_device_allowed(hass: core.HomeAssistant): await hass.async_block_till_done() assert len(mock_setup.mock_calls) == 0 assert len(mock_setup_entry.mock_calls) == 0 + + +async def test_zeroconf_form(hass: core.HomeAssistant): + """Test we get the discovery form.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data={"name": "test-bond-id.some-other-tail-info", "host": "test-host"}, + ) + assert result["type"] == "form" + assert result["errors"] == {} + + with patch_bond_version( + return_value={"bondid": "test-bond-id"} + ), patch_bond_device_ids(), _patch_async_setup() as mock_setup, _patch_async_setup_entry() as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_ACCESS_TOKEN: "test-token"}, + ) + + assert result2["type"] == "create_entry" + assert result2["title"] == "test-bond-id" + assert result2["data"] == { + CONF_HOST: "test-host", + CONF_ACCESS_TOKEN: "test-token", + } + await hass.async_block_till_done() + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_zeroconf_already_configured(hass: core.HomeAssistant): + """Test starting a flow from discovery when already configured.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="already-registered-bond-id", + data={CONF_HOST: "stored-host", CONF_ACCESS_TOKEN: "test-token"}, + ) + entry.add_to_hass(hass) + + with _patch_async_setup() as mock_setup, _patch_async_setup_entry() as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data={ + "name": "already-registered-bond-id.some-other-tail-info", + "host": "updated-host", + }, + ) + + assert result["type"] == "abort" + assert result["reason"] == "already_configured" + assert entry.data["host"] == "updated-host" + + await hass.async_block_till_done() + assert len(mock_setup.mock_calls) == 0 + assert len(mock_setup_entry.mock_calls) == 0 + + +async def test_zeroconf_form_unexpected_error(hass: core.HomeAssistant): + """Test we handle unexpected error gracefully.""" + await _help_test_form_unexpected_error( + hass, + source=config_entries.SOURCE_ZEROCONF, + initial_input={ + "name": "test-bond-id.some-other-tail-info", + "host": "test-host", + }, + user_input={CONF_ACCESS_TOKEN: "test-token"}, + error=Exception(), + ) + + +async def _help_test_form_unexpected_error( + hass: core.HomeAssistant, + *, + source: str, + initial_input: Dict[str, Any] = None, + user_input: Dict[str, Any], + error: Exception, +): + """Test we handle unexpected error gracefully.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": source}, data=initial_input + ) + + with patch_bond_version( + return_value={"bond_id": "test-bond-id"} + ), patch_bond_device_ids(side_effect=error): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "unknown"} + + +def _patch_async_setup(): + return patch("homeassistant.components.bond.async_setup", return_value=True) + + +def _patch_async_setup_entry(): + return patch("homeassistant.components.bond.async_setup_entry", return_value=True,)