From 77372d9094c48cf0f0c67b6631444d0665564ee6 Mon Sep 17 00:00:00 2001 From: Guido Schmitz Date: Thu, 22 Apr 2021 20:38:56 +0200 Subject: [PATCH] Add zeroconf detection to devolo Home Control (#47934) Co-authored-by: Markus Bong <2Fake1987@gmail.com> --- .../devolo_home_control/__init__.py | 14 ++- .../devolo_home_control/config_flow.py | 54 +++++++---- .../components/devolo_home_control/const.py | 1 + .../devolo_home_control/manifest.json | 3 +- .../devolo_home_control/strings.json | 11 ++- .../devolo_home_control/translations/en.json | 8 +- homeassistant/generated/zeroconf.py | 5 + tests/components/devolo_home_control/const.py | 22 +++++ .../devolo_home_control/test_config_flow.py | 96 ++++++++++++++----- 9 files changed, 161 insertions(+), 53 deletions(-) create mode 100644 tests/components/devolo_home_control/const.py diff --git a/homeassistant/components/devolo_home_control/__init__.py b/homeassistant/components/devolo_home_control/__init__.py index e9620f19551..a6918e81998 100644 --- a/homeassistant/components/devolo_home_control/__init__.py +++ b/homeassistant/components/devolo_home_control/__init__.py @@ -12,14 +12,20 @@ from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, EVENT_HOMEASSISTAN from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from .const import CONF_MYDEVOLO, DOMAIN, GATEWAY_SERIAL_PATTERN, PLATFORMS +from .const import ( + CONF_MYDEVOLO, + DEFAULT_MYDEVOLO, + DOMAIN, + GATEWAY_SERIAL_PATTERN, + PLATFORMS, +) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up the devolo account from a config entry.""" hass.data.setdefault(DOMAIN, {}) - mydevolo = _mydevolo(entry.data) + mydevolo = configure_mydevolo(entry.data) credentials_valid = await hass.async_add_executor_job(mydevolo.credentials_valid) @@ -92,10 +98,10 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return unload -def _mydevolo(conf: dict) -> Mydevolo: +def configure_mydevolo(conf: dict) -> Mydevolo: """Configure mydevolo.""" mydevolo = Mydevolo() mydevolo.user = conf[CONF_USERNAME] mydevolo.password = conf[CONF_PASSWORD] - mydevolo.url = conf[CONF_MYDEVOLO] + mydevolo.url = conf.get(CONF_MYDEVOLO, DEFAULT_MYDEVOLO) return mydevolo diff --git a/homeassistant/components/devolo_home_control/config_flow.py b/homeassistant/components/devolo_home_control/config_flow.py index d6dbd331d5f..43bacfed639 100644 --- a/homeassistant/components/devolo_home_control/config_flow.py +++ b/homeassistant/components/devolo_home_control/config_flow.py @@ -1,14 +1,20 @@ """Config flow to configure the devolo home control integration.""" import logging -from devolo_home_control_api.mydevolo import Mydevolo import voluptuous as vol from homeassistant import config_entries from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import callback +from homeassistant.helpers.typing import DiscoveryInfoType -from .const import CONF_MYDEVOLO, DEFAULT_MYDEVOLO, DOMAIN +from . import configure_mydevolo +from .const import ( # pylint:disable=unused-import + CONF_MYDEVOLO, + DEFAULT_MYDEVOLO, + DOMAIN, + SUPPORTED_MODEL_TYPES, +) _LOGGER = logging.getLogger(__name__) @@ -29,22 +35,30 @@ class DevoloHomeControlFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): async def async_step_user(self, user_input=None): """Handle a flow initiated by the user.""" if self.show_advanced_options: - self.data_schema = { - vol.Required(CONF_USERNAME): str, - vol.Required(CONF_PASSWORD): str, - vol.Required(CONF_MYDEVOLO, default=DEFAULT_MYDEVOLO): str, - } + self.data_schema[ + vol.Required(CONF_MYDEVOLO, default=DEFAULT_MYDEVOLO) + ] = str if user_input is None: return self._show_form(user_input) - user = user_input[CONF_USERNAME] - password = user_input[CONF_PASSWORD] - mydevolo = Mydevolo() - mydevolo.user = user - mydevolo.password = password - if self.show_advanced_options: - mydevolo.url = user_input[CONF_MYDEVOLO] - else: - mydevolo.url = DEFAULT_MYDEVOLO + return await self._connect_mydevolo(user_input) + + async def async_step_zeroconf(self, discovery_info: DiscoveryInfoType): + """Handle zeroconf discovery.""" + # Check if it is a gateway + if discovery_info.get("properties", {}).get("MT") in SUPPORTED_MODEL_TYPES: + await self._async_handle_discovery_without_unique_id() + return await self.async_step_zeroconf_confirm() + return self.async_abort(reason="Not a devolo Home Control gateway.") + + async def async_step_zeroconf_confirm(self, user_input=None): + """Handle a flow initiated by zeroconf.""" + if user_input is None: + return self._show_form(step_id="zeroconf_confirm") + return await self._connect_mydevolo(user_input) + + async def _connect_mydevolo(self, user_input): + """Connect to mydevolo.""" + mydevolo = configure_mydevolo(conf=user_input) credentials_valid = await self.hass.async_add_executor_job( mydevolo.credentials_valid ) @@ -58,17 +72,17 @@ class DevoloHomeControlFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return self.async_create_entry( title="devolo Home Control", data={ - CONF_PASSWORD: password, - CONF_USERNAME: user, + CONF_PASSWORD: mydevolo.password, + CONF_USERNAME: mydevolo.user, CONF_MYDEVOLO: mydevolo.url, }, ) @callback - def _show_form(self, errors=None): + def _show_form(self, errors=None, step_id="user"): """Show the form to the user.""" return self.async_show_form( - step_id="user", + step_id=step_id, data_schema=vol.Schema(self.data_schema), errors=errors if errors else {}, ) diff --git a/homeassistant/components/devolo_home_control/const.py b/homeassistant/components/devolo_home_control/const.py index 3a7d26435ff..b15c0acf622 100644 --- a/homeassistant/components/devolo_home_control/const.py +++ b/homeassistant/components/devolo_home_control/const.py @@ -6,3 +6,4 @@ DEFAULT_MYDEVOLO = "https://www.mydevolo.com" PLATFORMS = ["binary_sensor", "climate", "cover", "light", "sensor", "switch"] CONF_MYDEVOLO = "mydevolo_url" GATEWAY_SERIAL_PATTERN = re.compile(r"\d{16}") +SUPPORTED_MODEL_TYPES = ["2600", "2601"] diff --git a/homeassistant/components/devolo_home_control/manifest.json b/homeassistant/components/devolo_home_control/manifest.json index 832eb8025bc..5886c1d0fe2 100644 --- a/homeassistant/components/devolo_home_control/manifest.json +++ b/homeassistant/components/devolo_home_control/manifest.json @@ -7,5 +7,6 @@ "config_flow": true, "codeowners": ["@2Fake", "@Shutgun"], "quality_scale": "silver", - "iot_class": "local_push" + "iot_class": "local_push", + "zeroconf": ["_dvl-deviceapi._tcp.local."] } diff --git a/homeassistant/components/devolo_home_control/strings.json b/homeassistant/components/devolo_home_control/strings.json index 7624beb531c..cbc911fcd18 100644 --- a/homeassistant/components/devolo_home_control/strings.json +++ b/homeassistant/components/devolo_home_control/strings.json @@ -11,10 +11,17 @@ "data": { "username": "[%key:common::config_flow::data::email%] / devolo ID", "password": "[%key:common::config_flow::data::password%]", - "mydevolo_url": "mydevolo [%key:common::config_flow::data::url%]", - "home_control_url": "Home Control [%key:common::config_flow::data::url%]" + "mydevolo_url": "mydevolo [%key:common::config_flow::data::url%]" + } + }, + "zeroconf_confirm": { + "data": { + "username": "[%key:common::config_flow::data::email%] / devolo ID", + "password": "[%key:common::config_flow::data::password%]", + "mydevolo_url": "mydevolo [%key:common::config_flow::data::url%]" } } } } } + diff --git a/homeassistant/components/devolo_home_control/translations/en.json b/homeassistant/components/devolo_home_control/translations/en.json index 10485c94b6f..d1b8645072f 100644 --- a/homeassistant/components/devolo_home_control/translations/en.json +++ b/homeassistant/components/devolo_home_control/translations/en.json @@ -9,7 +9,13 @@ "step": { "user": { "data": { - "home_control_url": "Home Control URL", + "mydevolo_url": "mydevolo URL", + "password": "Password", + "username": "Email / devolo ID" + } + }, + "zeroconf_confirm": { + "data": { "mydevolo_url": "mydevolo URL", "password": "Password", "username": "Email / devolo ID" diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index f1485bc6e87..4c017b07628 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -49,6 +49,11 @@ ZEROCONF = { "domain": "daikin" } ], + "_dvl-deviceapi._tcp.local.": [ + { + "domain": "devolo_home_control" + } + ], "_elg._tcp.local.": [ { "domain": "elgato" diff --git a/tests/components/devolo_home_control/const.py b/tests/components/devolo_home_control/const.py new file mode 100644 index 00000000000..33a98a15e2d --- /dev/null +++ b/tests/components/devolo_home_control/const.py @@ -0,0 +1,22 @@ +"""Constants used for mocking data.""" + +DISCOVERY_INFO = { + "host": "192.168.0.1", + "port": 14791, + "hostname": "test.local.", + "type": "_dvl-deviceapi._tcp.local.", + "name": "dvl-deviceapi", + "properties": { + "Path": "/deviceapi", + "Version": "v0", + "Features": "", + "MT": "2600", + "SN": "1234567890", + "FirmwareVersion": "8.90.4", + "PlcMacAddress": "AA:BB:CC:DD:EE:FF", + }, +} + +DISCOVERY_INFO_WRONG_DEVOLO_DEVICE = {"properties": {"MT": "2700"}} + +DISCOVERY_INFO_WRONG_DEVICE = {"properties": {"Features": ""}} diff --git a/tests/components/devolo_home_control/test_config_flow.py b/tests/components/devolo_home_control/test_config_flow.py index 370d86c7c94..0b02cb9f4a1 100644 --- a/tests/components/devolo_home_control/test_config_flow.py +++ b/tests/components/devolo_home_control/test_config_flow.py @@ -4,9 +4,15 @@ from unittest.mock import patch import pytest from homeassistant import config_entries, data_entry_flow, setup -from homeassistant.components.devolo_home_control.const import DOMAIN +from homeassistant.components.devolo_home_control.const import DEFAULT_MYDEVOLO, DOMAIN from homeassistant.config_entries import SOURCE_USER +from .const import ( + DISCOVERY_INFO, + DISCOVERY_INFO_WRONG_DEVICE, + DISCOVERY_INFO_WRONG_DEVOLO_DEVICE, +) + from tests.common import MockConfigEntry @@ -19,28 +25,7 @@ async def test_form(hass): assert result["type"] == "form" assert result["errors"] == {} - with patch( - "homeassistant.components.devolo_home_control.async_setup_entry", - return_value=True, - ) as mock_setup_entry, patch( - "homeassistant.components.devolo_home_control.config_flow.Mydevolo.uuid", - return_value="123456", - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"username": "test-username", "password": "test-password"}, - ) - await hass.async_block_till_done() - - assert result2["type"] == "create_entry" - assert result2["title"] == "devolo Home Control" - assert result2["data"] == { - "username": "test-username", - "password": "test-password", - "mydevolo_url": "https://www.mydevolo.com", - } - - assert len(mock_setup_entry.mock_calls) == 1 + await _setup(hass, result) @pytest.mark.credentials_invalid @@ -64,7 +49,7 @@ async def test_form_invalid_credentials(hass): async def test_form_already_configured(hass): """Test if we get the error message on already configured.""" with patch( - "homeassistant.components.devolo_home_control.config_flow.Mydevolo.uuid", + "homeassistant.components.devolo_home_control.Mydevolo.uuid", return_value="123456", ): MockConfigEntry(domain=DOMAIN, unique_id="123456", data={}).add_to_hass(hass) @@ -89,7 +74,7 @@ async def test_form_advanced_options(hass): "homeassistant.components.devolo_home_control.async_setup_entry", return_value=True, ) as mock_setup_entry, patch( - "homeassistant.components.devolo_home_control.config_flow.Mydevolo.uuid", + "homeassistant.components.devolo_home_control.Mydevolo.uuid", return_value="123456", ): result2 = await hass.config_entries.flow.async_configure( @@ -111,3 +96,64 @@ async def test_form_advanced_options(hass): } assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_show_zeroconf_form(hass): + """Test that the zeroconf confirmation form is served.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=DISCOVERY_INFO, + ) + + assert result["step_id"] == "zeroconf_confirm" + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + + await _setup(hass, result) + + +async def test_zeroconf_wrong_device(hass): + """Test that the zeroconf ignores wrong devices.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=DISCOVERY_INFO_WRONG_DEVOLO_DEVICE, + ) + + assert result["reason"] == "Not a devolo Home Control gateway." + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=DISCOVERY_INFO_WRONG_DEVICE, + ) + + assert result["reason"] == "Not a devolo Home Control gateway." + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + + +async def _setup(hass, result): + """Finish configuration steps.""" + with patch( + "homeassistant.components.devolo_home_control.async_setup_entry", + return_value=True, + ) as mock_setup_entry, patch( + "homeassistant.components.devolo_home_control.Mydevolo.uuid", + return_value="123456", + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"username": "test-username", "password": "test-password"}, + ) + await hass.async_block_till_done() + + assert result2["type"] == "create_entry" + assert result2["title"] == "devolo Home Control" + assert result2["data"] == { + "username": "test-username", + "password": "test-password", + "mydevolo_url": DEFAULT_MYDEVOLO, + } + + assert len(mock_setup_entry.mock_calls) == 1