From 3627a9860207bfa1218ef85cd70ade3a8fa67d29 Mon Sep 17 00:00:00 2001 From: Robert Hillis Date: Sat, 14 Jan 2023 20:58:04 -0500 Subject: [PATCH] Add dhcp discovery to D-Link (#85661) Co-authored-by: J. Nick Koston --- homeassistant/components/dlink/config_flow.py | 68 ++++++++++- homeassistant/components/dlink/manifest.json | 1 + homeassistant/components/dlink/strings.json | 7 ++ .../components/dlink/translations/en.json | 7 ++ homeassistant/generated/dhcp.py | 4 + tests/components/dlink/conftest.py | 28 ++++- tests/components/dlink/test_config_flow.py | 107 +++++++++++++++++- 7 files changed, 212 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/dlink/config_flow.py b/homeassistant/components/dlink/config_flow.py index f1d1281c8d7..686448cfd81 100644 --- a/homeassistant/components/dlink/config_flow.py +++ b/homeassistant/components/dlink/config_flow.py @@ -8,6 +8,7 @@ from pyW215.pyW215 import SmartPlug import voluptuous as vol from homeassistant import config_entries +from homeassistant.components import dhcp from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME from homeassistant.data_entry_flow import FlowResult @@ -19,6 +20,58 @@ _LOGGER = logging.getLogger(__name__) class DLinkFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for D-Link Power Plug.""" + def __init__(self) -> None: + """Initialize a D-Link Power Plug flow.""" + self.ip_address: str | None = None + + async def async_step_dhcp(self, discovery_info: dhcp.DhcpServiceInfo) -> FlowResult: + """Handle dhcp discovery.""" + await self.async_set_unique_id(discovery_info.macaddress) + self._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.ip}) + for entry in self.hass.config_entries.async_entries(DOMAIN): + if not entry.unique_id and entry.data[CONF_HOST] == discovery_info.ip: + # Add mac address as the unique id, can be removed with import + self.hass.config_entries.async_update_entry( + entry, unique_id=discovery_info.macaddress + ) + return self.async_abort(reason="already_configured") + + self.ip_address = discovery_info.ip + return await self.async_step_confirm_discovery() + + async def async_step_confirm_discovery( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Allow the user to confirm adding the device.""" + errors = {} + if user_input is not None: + if ( + error := await self.hass.async_add_executor_job( + self._try_connect, user_input + ) + ) is None: + return self.async_create_entry( + title=DEFAULT_NAME, + data=user_input | {CONF_HOST: self.ip_address}, + ) + errors["base"] = error + + user_input = user_input or {} + return self.async_show_form( + step_id="confirm_discovery", + data_schema=vol.Schema( + { + vol.Optional( + CONF_USERNAME, + default=user_input.get(CONF_USERNAME, DEFAULT_USERNAME), + ): str, + vol.Required(CONF_PASSWORD): str, + vol.Required(CONF_USE_LEGACY_PROTOCOL): bool, + } + ), + errors=errors, + ) + async def async_step_import(self, config: dict[str, Any]) -> FlowResult: """Import a config entry.""" self._async_abort_entries_match({CONF_HOST: config[CONF_HOST]}) @@ -36,10 +89,11 @@ class DLinkFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): if user_input is not None: self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]}) - error = await self.hass.async_add_executor_job( - self._try_connect, user_input - ) - if error is None: + if ( + error := await self.hass.async_add_executor_job( + self._try_connect, user_input + ) + ) is None: return self.async_create_entry( title=DEFAULT_NAME, data=user_input, @@ -51,7 +105,9 @@ class DLinkFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): step_id="user", data_schema=vol.Schema( { - vol.Required(CONF_HOST, default=user_input.get(CONF_HOST, "")): str, + vol.Required( + CONF_HOST, default=user_input.get(CONF_HOST, self.ip_address) + ): str, vol.Optional( CONF_USERNAME, default=user_input.get(CONF_USERNAME, DEFAULT_USERNAME), @@ -67,7 +123,7 @@ class DLinkFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Try connecting to D-Link Power Plug.""" try: smartplug = SmartPlug( - user_input[CONF_HOST], + user_input.get(CONF_HOST, self.ip_address), user_input[CONF_PASSWORD], user_input[CONF_USERNAME], user_input[CONF_USE_LEGACY_PROTOCOL], diff --git a/homeassistant/components/dlink/manifest.json b/homeassistant/components/dlink/manifest.json index 8cb07c774f1..112e771839b 100644 --- a/homeassistant/components/dlink/manifest.json +++ b/homeassistant/components/dlink/manifest.json @@ -4,6 +4,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/dlink", "requirements": ["pyW215==0.7.0"], + "dhcp": [{ "hostname": "dsp-w215" }], "codeowners": ["@tkdrob"], "iot_class": "local_polling", "loggers": ["pyW215"], diff --git a/homeassistant/components/dlink/strings.json b/homeassistant/components/dlink/strings.json index f0527628192..9ac7453093c 100644 --- a/homeassistant/components/dlink/strings.json +++ b/homeassistant/components/dlink/strings.json @@ -8,6 +8,13 @@ "username": "[%key:common::config_flow::data::username%]", "use_legacy_protocol": "Use legacy protocol" } + }, + "confirm_discovery": { + "data": { + "password": "[%key:component::dlink::config::step::user::data::password%]", + "username": "[%key:common::config_flow::data::username%]", + "use_legacy_protocol": "[%key:component::dlink::config::step::user::data::use_legacy_protocol%]" + } } }, "error": { diff --git a/homeassistant/components/dlink/translations/en.json b/homeassistant/components/dlink/translations/en.json index b863f66d32a..3b1f065274a 100644 --- a/homeassistant/components/dlink/translations/en.json +++ b/homeassistant/components/dlink/translations/en.json @@ -15,6 +15,13 @@ "use_legacy_protocol": "Use legacy protocol", "username": "Username" } + }, + "confirm_discovery": { + "data": { + "password": "Password (default: PIN code on the back)", + "use_legacy_protocol": "Use legacy protocol", + "username": "Username" + } } } }, diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index 8b8a24de876..d1aa50a0faf 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -97,6 +97,10 @@ DHCP: list[dict[str, str | bool]] = [ "domain": "broadlink", "macaddress": "C8F742*", }, + { + "domain": "dlink", + "hostname": "dsp-w215", + }, { "domain": "elkm1", "registered_devices": True, diff --git a/tests/components/dlink/conftest.py b/tests/components/dlink/conftest.py index 813a957abdf..4e064b35d5f 100644 --- a/tests/components/dlink/conftest.py +++ b/tests/components/dlink/conftest.py @@ -5,25 +5,41 @@ from unittest.mock import MagicMock, patch import pytest +from homeassistant.components import dhcp from homeassistant.components.dlink.const import CONF_USE_LEGACY_PROTOCOL, DOMAIN from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import format_mac from tests.common import MockConfigEntry HOST = "1.2.3.4" PASSWORD = "123456" +MAC = format_mac("AA:BB:CC:DD:EE:FF") USERNAME = "admin" -CONF_DATA = { - CONF_HOST: HOST, +CONF_DHCP_DATA = { CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD, CONF_USE_LEGACY_PROTOCOL: True, } +CONF_DATA = CONF_DHCP_DATA | {CONF_HOST: HOST} + CONF_IMPORT_DATA = CONF_DATA | {CONF_NAME: "Smart Plug"} +CONF_DHCP_FLOW = dhcp.DhcpServiceInfo( + ip=HOST, + macaddress=MAC, + hostname="dsp-w215", +) + +CONF_DHCP_FLOW_NEW_IP = dhcp.DhcpServiceInfo( + ip="5.6.7.8", + macaddress=MAC, + hostname="dsp-w215", +) + def create_entry(hass: HomeAssistant) -> MockConfigEntry: """Create fixture for adding config entry in Home Assistant.""" @@ -38,6 +54,14 @@ def config_entry(hass: HomeAssistant) -> MockConfigEntry: return create_entry(hass) +@pytest.fixture() +def config_entry_with_uid(hass: HomeAssistant) -> MockConfigEntry: + """Add config entry with unique ID in Home Assistant.""" + config_entry = create_entry(hass) + config_entry.unique_id = "aa:bb:cc:dd:ee:ff" + return config_entry + + @pytest.fixture() def mocked_plug() -> MagicMock: """Create mocked plug device.""" diff --git a/tests/components/dlink/test_config_flow.py b/tests/components/dlink/test_config_flow.py index dc4064211ca..3e5bdf2106a 100644 --- a/tests/components/dlink/test_config_flow.py +++ b/tests/components/dlink/test_config_flow.py @@ -2,11 +2,20 @@ from unittest.mock import MagicMock, patch from homeassistant import data_entry_flow +from homeassistant.components import dhcp from homeassistant.components.dlink.const import DEFAULT_NAME, DOMAIN -from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.config_entries import SOURCE_DHCP, SOURCE_IMPORT, SOURCE_USER +from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant -from .conftest import CONF_DATA, CONF_IMPORT_DATA, patch_config_flow +from .conftest import ( + CONF_DATA, + CONF_DHCP_DATA, + CONF_DHCP_FLOW, + CONF_DHCP_FLOW_NEW_IP, + CONF_IMPORT_DATA, + patch_config_flow, +) from tests.common import MockConfigEntry @@ -99,3 +108,97 @@ async def test_import(hass: HomeAssistant, mocked_plug: MagicMock) -> None: assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY assert result["title"] == "Smart Plug" assert result["data"] == CONF_DATA + + +async def test_dhcp(hass: HomeAssistant, mocked_plug: MagicMock) -> None: + """Test we can process the discovery from dhcp.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_DHCP}, data=CONF_DHCP_FLOW + ) + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "confirm_discovery" + with patch_config_flow(mocked_plug), _patch_setup_entry(): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=CONF_DHCP_DATA, + ) + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["title"] == DEFAULT_NAME + assert result["data"] == CONF_DATA + + +async def test_dhcp_failed_auth( + hass: HomeAssistant, mocked_plug: MagicMock, mocked_plug_no_auth: MagicMock +) -> None: + """Test we can recovery from failed authentication during dhcp flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_DHCP}, data=CONF_DHCP_FLOW + ) + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "confirm_discovery" + with patch_config_flow(mocked_plug_no_auth): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=CONF_DHCP_DATA, + ) + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["errors"]["base"] == "cannot_connect" + + with patch_config_flow(mocked_plug), _patch_setup_entry(): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=CONF_DHCP_DATA, + ) + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["title"] == DEFAULT_NAME + assert result["data"] == CONF_DATA + + +async def test_dhcp_already_configured( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """Test dhcp initialized flow with duplicate server.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_DHCP}, data=CONF_DHCP_FLOW + ) + + assert result["type"] == data_entry_flow.FlowResultType.ABORT + assert result["reason"] == "already_configured" + assert config_entry.unique_id == "aa:bb:cc:dd:ee:ff" + + +async def test_dhcp_unique_id_assignment( + hass: HomeAssistant, mocked_plug: MagicMock +) -> None: + """Test dhcp initialized flow with no unique id for matching entry.""" + dhcp_data = dhcp.DhcpServiceInfo( + ip="2.3.4.5", + macaddress="11:22:33:44:55:66", + hostname="dsp-w215", + ) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_DHCP}, data=dhcp_data + ) + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "confirm_discovery" + with patch_config_flow(mocked_plug), _patch_setup_entry(): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=CONF_DHCP_DATA, + ) + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["data"] == CONF_DATA | {CONF_HOST: "2.3.4.5"} + assert result["result"].unique_id == "11:22:33:44:55:66" + + +async def test_dhcp_changed_ip( + hass: HomeAssistant, config_entry_with_uid: MockConfigEntry +) -> None: + """Test that we successfully change IP address for device with known mac address.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_DHCP}, data=CONF_DHCP_FLOW_NEW_IP + ) + + assert result["type"] == data_entry_flow.FlowResultType.ABORT + assert result["reason"] == "already_configured" + assert config_entry_with_uid.data[CONF_HOST] == "5.6.7.8"