Add zeroconf discovery for bond integration (#38448)

* Add zeroconf discovery for bond integration

* Add zeroconf discovery for bond integration (fix typo)

* Add zeroconf discovery for bond integration (PR feedback)

* Add zeroconf discovery for bond integration (PR feedback)

* Add zeroconf discovery for bond integration (PR feedback)
This commit is contained in:
Eugene Prystupa 2020-08-01 12:18:40 -04:00 committed by GitHub
parent c3a820c4a3
commit 11994d207a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 222 additions and 57 deletions

View File

@ -1,23 +1,26 @@
"""Config flow for Bond integration.""" """Config flow for Bond integration."""
import logging import logging
from typing import Any, Dict, Optional
from aiohttp import ClientConnectionError, ClientResponseError from aiohttp import ClientConnectionError, ClientResponseError
from bond_api import Bond from bond_api import Bond
import voluptuous as vol import voluptuous as vol
from homeassistant import config_entries, exceptions 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 from .const import DOMAIN # pylint:disable=unused-import
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
DATA_SCHEMA = vol.Schema( DATA_SCHEMA_USER = vol.Schema(
{vol.Required(CONF_HOST): str, vol.Required(CONF_ACCESS_TOKEN): str} {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.""" """Validate the user input allows us to connect."""
try: try:
@ -26,11 +29,14 @@ async def validate_input(data) -> str:
# call to non-version API is needed to validate authentication # call to non-version API is needed to validate authentication
await bond.devices() await bond.devices()
except ClientConnectionError: except ClientConnectionError:
raise CannotConnect raise InputValidationError("cannot_connect")
except ClientResponseError as error: except ClientResponseError as error:
if error.status == 401: if error.status == 401:
raise InvalidAuth raise InputValidationError("invalid_auth")
raise 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 unique ID from the hub to be stored in the config entry.
return version["bondid"] return version["bondid"]
@ -42,32 +48,73 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
VERSION = 1 VERSION = 1
CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL
async def async_step_user(self, user_input=None): _discovered: dict = None
"""Handle the initial step."""
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 = {} errors = {}
if user_input is not None: if user_input is not None:
try: try:
bond_id = await validate_input(user_input) return await self._try_create_entry(user_input)
except CannotConnect: except InputValidationError as error:
errors["base"] = "cannot_connect" errors["base"] = error.base
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 self.async_show_form( return self.async_show_form(
step_id="user", data_schema=DATA_SCHEMA, errors=errors step_id="user", data_schema=DATA_SCHEMA_USER, errors=errors
) )
async def _try_create_entry(self, data: Dict[str, Any]) -> Dict[str, Any]:
class CannotConnect(exceptions.HomeAssistantError): bond_id = await _validate_input(data)
"""Error to indicate we cannot connect.""" 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): class InputValidationError(exceptions.HomeAssistantError):
"""Error to indicate there is invalid auth.""" """Error to indicate we cannot proceed due to invalid input."""
def __init__(self, base: str):
"""Initialize with error base."""
super().__init__()
self.base = base

View File

@ -1,3 +1,5 @@
"""Constants for the Bond integration.""" """Constants for the Bond integration."""
DOMAIN = "bond" DOMAIN = "bond"
CONF_BOND_ID: str = "bond_id"

View File

@ -4,6 +4,7 @@
"config_flow": true, "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/bond", "documentation": "https://www.home-assistant.io/integrations/bond",
"requirements": ["bond-api==0.1.8"], "requirements": ["bond-api==0.1.8"],
"zeroconf": ["_bond._tcp.local."],
"codeowners": ["@prystupa"], "codeowners": ["@prystupa"],
"quality_scale": "platinum" "quality_scale": "platinum"
} }

View File

@ -1,6 +1,13 @@
{ {
"config": { "config": {
"flow_title": "Bond: {bond_id} ({host})",
"step": { "step": {
"confirm": {
"description": "Do you want to set up {bond_id}?",
"data": {
"access_token": "[%key:common::config_flow::data::access_token%]"
}
},
"user": { "user": {
"data": { "data": {
"host": "[%key:common::config_flow::data::host%]", "host": "[%key:common::config_flow::data::host%]",

View File

@ -8,7 +8,14 @@
"invalid_auth": "Invalid authentication", "invalid_auth": "Invalid authentication",
"unknown": "Unexpected error" "unknown": "Unexpected error"
}, },
"flow_title": "Bond: {bond_id} ({host})",
"step": { "step": {
"confirm": {
"data": {
"access_token": "Access Token"
},
"description": "Do you want to set up {bond_id}?"
},
"user": { "user": {
"data": { "data": {
"access_token": "Access Token", "access_token": "Access Token",

View File

@ -16,6 +16,9 @@ ZEROCONF = {
"axis", "axis",
"doorbird" "doorbird"
], ],
"_bond._tcp.local.": [
"bond"
],
"_daap._tcp.local.": [ "_daap._tcp.local.": [
"forked_daapd" "forked_daapd"
], ],

View File

@ -1,4 +1,5 @@
"""Test the Bond config flow.""" """Test the Bond config flow."""
from typing import Any, Dict
from aiohttp import ClientConnectionError, ClientResponseError from aiohttp import ClientConnectionError, ClientResponseError
@ -12,8 +13,8 @@ from tests.async_mock import Mock, patch
from tests.common import MockConfigEntry from tests.common import MockConfigEntry
async def test_form(hass: core.HomeAssistant): async def test_user_form(hass: core.HomeAssistant):
"""Test we get the form.""" """Test we get the user initiated form."""
await setup.async_setup_component(hass, "persistent_notification", {}) await setup.async_setup_component(hass, "persistent_notification", {})
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER} DOMAIN, context={"source": config_entries.SOURCE_USER}
@ -23,11 +24,7 @@ async def test_form(hass: core.HomeAssistant):
with patch_bond_version( with patch_bond_version(
return_value={"bondid": "test-bond-id"} return_value={"bondid": "test-bond-id"}
), patch_bond_device_ids(), patch( ), patch_bond_device_ids(), _patch_async_setup() as mock_setup, _patch_async_setup_entry() as mock_setup_entry:
"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:
result2 = await hass.config_entries.flow.async_configure( result2 = await hass.config_entries.flow.async_configure(
result["flow_id"], result["flow_id"],
{CONF_HOST: "some host", CONF_ACCESS_TOKEN: "test-token"}, {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 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.""" """Test we handle invalid auth."""
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER} 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"} 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.""" """Test we handle cannot connect error."""
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER} 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"} assert result2["errors"] == {"base": "cannot_connect"}
async def test_form_unexpected_error(hass: core.HomeAssistant): 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),
)
async def test_user_form_unexpected_error(hass: core.HomeAssistant):
"""Test we handle unexpected error gracefully.""" """Test we handle unexpected error gracefully."""
result = await hass.config_entries.flow.async_init( await _help_test_form_unexpected_error(
DOMAIN, context={"source": config_entries.SOURCE_USER} hass,
source=config_entries.SOURCE_USER,
user_input={CONF_HOST: "some host", CONF_ACCESS_TOKEN: "test-token"},
error=Exception(),
) )
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" async def test_user_form_one_entry_per_device_allowed(hass: core.HomeAssistant):
assert result2["errors"] == {"base": "unknown"}
async def test_form_one_entry_per_device_allowed(hass: core.HomeAssistant):
"""Test that only one entry allowed per unique ID reported by Bond hub device.""" """Test that only one entry allowed per unique ID reported by Bond hub device."""
MockConfigEntry( MockConfigEntry(
domain=DOMAIN, domain=DOMAIN,
@ -118,11 +115,7 @@ async def test_form_one_entry_per_device_allowed(hass: core.HomeAssistant):
with patch_bond_version( with patch_bond_version(
return_value={"bondid": "already-registered-bond-id"} return_value={"bondid": "already-registered-bond-id"}
), patch_bond_device_ids(), patch( ), patch_bond_device_ids(), _patch_async_setup() as mock_setup, _patch_async_setup_entry() as mock_setup_entry:
"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:
result2 = await hass.config_entries.flow.async_configure( result2 = await hass.config_entries.flow.async_configure(
result["flow_id"], result["flow_id"],
{CONF_HOST: "some host", CONF_ACCESS_TOKEN: "test-token"}, {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() await hass.async_block_till_done()
assert len(mock_setup.mock_calls) == 0 assert len(mock_setup.mock_calls) == 0
assert len(mock_setup_entry.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,)