diff --git a/homeassistant/components/hunterdouglas_powerview/config_flow.py b/homeassistant/components/hunterdouglas_powerview/config_flow.py index ec81f25c0f2..869d703b641 100644 --- a/homeassistant/components/hunterdouglas_powerview/config_flow.py +++ b/homeassistant/components/hunterdouglas_powerview/config_flow.py @@ -1,11 +1,14 @@ """Config flow for Hunter Douglas PowerView integration.""" +from __future__ import annotations + import logging from aiopvapi.helpers.aiorequest import AioRequest import async_timeout import voluptuous as vol -from homeassistant import config_entries, core, exceptions +from homeassistant import config_entries, core, data_entry_flow, exceptions +from homeassistant.components.dhcp import HOSTNAME, IP_ADDRESS from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -18,13 +21,12 @@ DATA_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str}) HAP_SUFFIX = "._hap._tcp.local." -async def validate_input(hass: core.HomeAssistant, data): +async def validate_input(hass: core.HomeAssistant, hub_address: str) -> dict[str, str]: """Validate the user input allows us to connect. Data has the keys from DATA_SCHEMA with values provided by the user. """ - hub_address = data[CONF_HOST] websession = async_get_clientsession(hass) pv_request = AioRequest(hub_address, loop=hass.loop, websession=websession) @@ -34,8 +36,6 @@ async def validate_input(hass: core.HomeAssistant, data): device_info = await async_get_device_info(pv_request) except HUB_EXCEPTIONS as err: raise CannotConnect from err - if not device_info: - raise CannotConnect # Return info that you want to store in the config entry. return { @@ -52,56 +52,75 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): def __init__(self): """Initialize the powerview config flow.""" self.powerview_config = {} + self.discovered_ip = None + self.discovered_name = None async def async_step_user(self, user_input=None): """Handle the initial step.""" errors = {} if user_input is not None: - if self._host_already_configured(user_input[CONF_HOST]): - return self.async_abort(reason="already_configured") - try: - info = await validate_input(self.hass, user_input) - except CannotConnect: - errors["base"] = "cannot_connect" - except Exception: # pylint: disable=broad-except - _LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" - - if not errors: + info, error = await self._async_validate_or_error(user_input[CONF_HOST]) + if not error: await self.async_set_unique_id(info["unique_id"]) return self.async_create_entry( title=info["title"], data={CONF_HOST: user_input[CONF_HOST]} ) + errors["base"] = error return self.async_show_form( step_id="user", data_schema=DATA_SCHEMA, errors=errors ) - async def async_step_homekit(self, discovery_info): - """Handle HomeKit discovery.""" - - # If we already have the host configured do - # not open connections to it if we can avoid it. - if self._host_already_configured(discovery_info[CONF_HOST]): - return self.async_abort(reason="already_configured") + async def _async_validate_or_error(self, host): + if self._host_already_configured(host): + raise data_entry_flow.AbortFlow("already_configured") try: - info = await validate_input(self.hass, discovery_info) + info = await validate_input(self.hass, host) except CannotConnect: - return self.async_abort(reason="cannot_connect") + return None, "cannot_connect" except Exception: # pylint: disable=broad-except - return self.async_abort(reason="unknown") + _LOGGER.exception("Unexpected exception") + return None, "unknown" - await self.async_set_unique_id(info["unique_id"], raise_on_progress=False) - self._abort_if_unique_id_configured({CONF_HOST: discovery_info["host"]}) + return info, None - name = discovery_info["name"] + async def async_step_dhcp(self, discovery_info): + """Handle DHCP discovery.""" + self.discovered_ip = discovery_info[IP_ADDRESS] + self.discovered_name = discovery_info[HOSTNAME] + return await self.async_step_discovery_confirm() + + async def async_step_homekit(self, discovery_info): + """Handle HomeKit discovery.""" + self.discovered_ip = discovery_info[CONF_HOST] + name = discovery_info[CONF_NAME] if name.endswith(HAP_SUFFIX): name = name[: -len(HAP_SUFFIX)] + self.discovered_name = name + return await self.async_step_discovery_confirm() + + async def async_step_discovery_confirm(self): + """Confirm dhcp or homekit discovery.""" + # If we already have the host configured do + # not open connections to it if we can avoid it. + for progress in self._async_in_progress(): + if progress.get("context", {}).get(CONF_HOST) == self.discovered_ip: + return self.async_abort(reason="already_in_progress") + + if self._host_already_configured(self.discovered_ip): + return self.async_abort(reason="already_configured") + + info, error = await self._async_validate_or_error(self.discovered_ip) + if error: + return self.async_abort(reason=error) + + await self.async_set_unique_id(info["unique_id"], raise_on_progress=False) + self._abort_if_unique_id_configured({CONF_HOST: self.discovered_ip}) self.powerview_config = { - CONF_HOST: discovery_info["host"], - CONF_NAME: name, + CONF_HOST: self.discovered_ip, + CONF_NAME: self.discovered_name, } return await self.async_step_link() @@ -113,6 +132,8 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): data={CONF_HOST: self.powerview_config[CONF_HOST]}, ) + self.context[CONF_HOST] = self.discovered_ip + self._set_confirm_only() return self.async_show_form( step_id="link", description_placeholders=self.powerview_config ) diff --git a/homeassistant/components/hunterdouglas_powerview/manifest.json b/homeassistant/components/hunterdouglas_powerview/manifest.json index 183f4b45472..15a993d6d48 100644 --- a/homeassistant/components/hunterdouglas_powerview/manifest.json +++ b/homeassistant/components/hunterdouglas_powerview/manifest.json @@ -8,5 +8,11 @@ "homekit": { "models": ["PowerView"] }, + "dhcp": [ + { + "hostname": "hunter*", + "macaddress": "002674*" + } + ], "iot_class": "local_polling" } diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index aa70d978e6c..d53cf133ac1 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -72,6 +72,11 @@ DHCP = [ "hostname": "flume-gw-*", "macaddress": "B4E62D*" }, + { + "domain": "hunterdouglas_powerview", + "hostname": "hunter*", + "macaddress": "002674*" + }, { "domain": "lyric", "hostname": "lyric-*", diff --git a/tests/components/hunterdouglas_powerview/test_config_flow.py b/tests/components/hunterdouglas_powerview/test_config_flow.py index f88e65ff854..60ee532c73f 100644 --- a/tests/components/hunterdouglas_powerview/test_config_flow.py +++ b/tests/components/hunterdouglas_powerview/test_config_flow.py @@ -3,11 +3,32 @@ import asyncio import json from unittest.mock import AsyncMock, MagicMock, patch +import pytest + from homeassistant import config_entries, setup from homeassistant.components.hunterdouglas_powerview.const import DOMAIN from tests.common import MockConfigEntry, load_fixture +HOMEKIT_DISCOVERY_INFO = { + "name": "Hunter Douglas Powerview Hub._hap._tcp.local.", + "host": "1.2.3.4", + "properties": {"id": "AA::BB::CC::DD::EE::FF"}, +} + +DHCP_DISCOVERY_INFO = {"hostname": "Hunter Douglas Powerview Hub", "ip": "1.2.3.4"} + +DISCOVERY_DATA = [ + ( + config_entries.SOURCE_HOMEKIT, + HOMEKIT_DISCOVERY_INFO, + ), + ( + config_entries.SOURCE_DHCP, + DHCP_DISCOVERY_INFO, + ), +] + def _get_mock_powerview_userdata(userdata=None, get_resources=None): mock_powerview_userdata = MagicMock() @@ -65,8 +86,36 @@ async def test_user_form(hass): assert result4["type"] == "abort" -async def test_form_homekit(hass): - """Test we get the form with homekit source.""" +@pytest.mark.parametrize("source, discovery_info", DISCOVERY_DATA) +async def test_form_homekit_and_dhcp_cannot_connect(hass, source, discovery_info): + """Test we get the form with homekit and dhcp source.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + + ignored_config_entry = MockConfigEntry( + domain=DOMAIN, data={}, source=config_entries.SOURCE_IGNORE + ) + ignored_config_entry.add_to_hass(hass) + + mock_powerview_userdata = _get_mock_powerview_userdata( + get_resources=asyncio.TimeoutError + ) + with patch( + "homeassistant.components.hunterdouglas_powerview.UserData", + return_value=mock_powerview_userdata, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": source}, + data=discovery_info, + ) + + assert result["type"] == "abort" + assert result["reason"] == "cannot_connect" + + +@pytest.mark.parametrize("source, discovery_info", DISCOVERY_DATA) +async def test_form_homekit_and_dhcp(hass, source, discovery_info): + """Test we get the form with homekit and dhcp source.""" await setup.async_setup_component(hass, "persistent_notification", {}) ignored_config_entry = MockConfigEntry( @@ -81,12 +130,8 @@ async def test_form_homekit(hass): ): result = await hass.config_entries.flow.async_init( DOMAIN, - context={"source": config_entries.SOURCE_HOMEKIT}, - data={ - "host": "1.2.3.4", - "properties": {"id": "AA::BB::CC::DD::EE::FF"}, - "name": "PowerViewHub._hap._tcp.local.", - }, + context={"source": source}, + data=discovery_info, ) assert result["type"] == "form" @@ -94,7 +139,7 @@ async def test_form_homekit(hass): assert result["errors"] is None assert result["description_placeholders"] == { "host": "1.2.3.4", - "name": "PowerViewHub", + "name": "Hunter Douglas Powerview Hub", } with patch( @@ -108,7 +153,7 @@ async def test_form_homekit(hass): await hass.async_block_till_done() assert result2["type"] == "create_entry" - assert result2["title"] == "PowerViewHub" + assert result2["title"] == "Hunter Douglas Powerview Hub" assert result2["data"] == {"host": "1.2.3.4"} assert result2["result"].unique_id == "ABC123" @@ -116,16 +161,44 @@ async def test_form_homekit(hass): result3 = await hass.config_entries.flow.async_init( DOMAIN, - context={"source": config_entries.SOURCE_HOMEKIT}, - data={ - "host": "1.2.3.4", - "properties": {"id": "AA::BB::CC::DD::EE::FF"}, - "name": "PowerViewHub._hap._tcp.local.", - }, + context={"source": source}, + data=discovery_info, ) assert result3["type"] == "abort" +async def test_discovered_by_homekit_and_dhcp(hass): + """Test we get the form with homekit and abort for dhcp source when we get both.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + + mock_powerview_userdata = _get_mock_powerview_userdata() + with patch( + "homeassistant.components.hunterdouglas_powerview.UserData", + return_value=mock_powerview_userdata, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_HOMEKIT}, + data=HOMEKIT_DISCOVERY_INFO, + ) + + assert result["type"] == "form" + assert result["step_id"] == "link" + + with patch( + "homeassistant.components.hunterdouglas_powerview.UserData", + return_value=mock_powerview_userdata, + ): + result2 = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=DHCP_DISCOVERY_INFO, + ) + + assert result2["type"] == "abort" + assert result2["reason"] == "already_in_progress" + + async def test_form_cannot_connect(hass): """Test we handle cannot connect error.""" result = await hass.config_entries.flow.async_init(