From 6a7b5657acc7a201fa4817c9e49f015a327e2e6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Mon, 4 Nov 2019 19:56:49 +0200 Subject: [PATCH] Support Huawei LTE SSDP discovery (#28214) * Support Huawei LTE SSDP discovery * Avoid KeyError on simultaneous user initiated flow Co-Authored-By: Paulus Schoutsen * Format code * Add already configured check * Initialize context in test flows * Move deviceType match to manifest * Update generated.ssdp * Add SSDP config flow test case * Remove stale debug print from tests --- .../huawei_lte/.translations/en.json | 4 +- .../components/huawei_lte/config_flow.py | 50 +++++++++++-- .../components/huawei_lte/manifest.json | 6 ++ .../components/huawei_lte/strings.json | 4 +- homeassistant/generated/ssdp.py | 6 ++ .../components/huawei_lte/test_config_flow.py | 73 ++++++++++++++----- 6 files changed, 116 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/huawei_lte/.translations/en.json b/homeassistant/components/huawei_lte/.translations/en.json index 8681e3355a4..0952b05a5cf 100644 --- a/homeassistant/components/huawei_lte/.translations/en.json +++ b/homeassistant/components/huawei_lte/.translations/en.json @@ -1,7 +1,9 @@ { "config": { "abort": { - "already_configured": "This device is already configured" + "already_configured": "This device has already been configured", + "already_in_progress": "This device is already being configured", + "not_huawei_lte": "Not a Huawei LTE device" }, "error": { "connection_failed": "Connection failed", diff --git a/homeassistant/components/huawei_lte/config_flow.py b/homeassistant/components/huawei_lte/config_flow.py index 992dc33a697..1bc3753bdd7 100644 --- a/homeassistant/components/huawei_lte/config_flow.py +++ b/homeassistant/components/huawei_lte/config_flow.py @@ -19,6 +19,7 @@ from url_normalize import url_normalize import voluptuous as vol from homeassistant import config_entries +from homeassistant.components.ssdp import ATTR_HOST, ATTR_NAME, ATTR_PRESENTATIONURL from homeassistant.const import CONF_PASSWORD, CONF_RECIPIENT, CONF_URL, CONF_USERNAME from homeassistant.core import callback from .const import CONNECTION_TIMEOUT, DEFAULT_DEVICE_NAME @@ -52,7 +53,14 @@ class ConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): ( ( vol.Required( - CONF_URL, default=user_input.get(CONF_URL, "") + CONF_URL, + default=user_input.get( + CONF_URL, + # https://github.com/PyCQA/pylint/issues/3167 + self.context.get( # pylint: disable=no-member + CONF_URL, "" + ), + ), ), str, ), @@ -78,6 +86,14 @@ class ConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Handle import initiated config flow.""" return await self.async_step_user(user_input) + def _already_configured(self, user_input): + """See if we already have a router matching user input configured.""" + existing_urls = { + url_normalize(entry.data[CONF_URL], default_scheme="http") + for entry in self._async_current_entries() + } + return user_input[CONF_URL] in existing_urls + async def async_step_user(self, user_input=None): """Handle user initiated config flow.""" if user_input is None: @@ -95,12 +111,7 @@ class ConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): user_input=user_input, errors=errors ) - # See if we already have a router configured with this URL - existing_urls = { # existing entries - url_normalize(entry.data[CONF_URL], default_scheme="http") - for entry in self._async_current_entries() - } - if user_input[CONF_URL] in existing_urls: + if self._already_configured(user_input): return self.async_abort(reason="already_configured") conn = None @@ -194,6 +205,31 @@ class ConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return self.async_create_entry(title=title, data=user_input) + async def async_step_ssdp(self, discovery_info): + """Handle SSDP initiated config flow.""" + # Attempt to distinguish from other non-LTE Huawei router devices, at least + # some ones we are interested in have "Mobile Wi-Fi" friendlyName. + if "mobile" not in discovery_info.get(ATTR_NAME, "").lower(): + return self.async_abort(reason="not_huawei_lte") + + # https://github.com/PyCQA/pylint/issues/3167 + url = self.context[CONF_URL] = url_normalize( # pylint: disable=no-member + discovery_info.get( + ATTR_PRESENTATIONURL, f"http://{discovery_info[ATTR_HOST]}/" + ) + ) + + if any( + url == flow["context"].get(CONF_URL) for flow in self._async_in_progress() + ): + return self.async_abort(reason="already_in_progress") + + user_input = {CONF_URL: url} + if self._already_configured(user_input): + return self.async_abort(reason="already_configured") + + return await self._async_show_user_form(user_input) + class OptionsFlowHandler(config_entries.OptionsFlow): """Huawei LTE options flow.""" diff --git a/homeassistant/components/huawei_lte/manifest.json b/homeassistant/components/huawei_lte/manifest.json index b3c4442caa9..4ea54188688 100644 --- a/homeassistant/components/huawei_lte/manifest.json +++ b/homeassistant/components/huawei_lte/manifest.json @@ -9,6 +9,12 @@ "stringcase==1.2.0", "url-normalize==1.4.1" ], + "ssdp": [ + { + "deviceType": "urn:schemas-upnp-org:device:InternetGatewayDevice:1", + "manufacturer": "Huawei" + } + ], "dependencies": [], "codeowners": [ "@scop" diff --git a/homeassistant/components/huawei_lte/strings.json b/homeassistant/components/huawei_lte/strings.json index 2e76cf1b343..17684253671 100644 --- a/homeassistant/components/huawei_lte/strings.json +++ b/homeassistant/components/huawei_lte/strings.json @@ -1,7 +1,9 @@ { "config": { "abort": { - "already_configured": "This device is already configured" + "already_configured": "This device has already been configured", + "already_in_progress": "This device is already being configured", + "not_huawei_lte": "Not a Huawei LTE device" }, "error": { "connection_failed": "Connection failed", diff --git a/homeassistant/generated/ssdp.py b/homeassistant/generated/ssdp.py index 472ad6683ed..adf3a345bbe 100644 --- a/homeassistant/generated/ssdp.py +++ b/homeassistant/generated/ssdp.py @@ -16,6 +16,12 @@ SSDP = { "st": "urn:schemas-denon-com:device:ACT-Denon:1" } ], + "huawei_lte": [ + { + "deviceType": "urn:schemas-upnp-org:device:InternetGatewayDevice:1", + "manufacturer": "Huawei" + } + ], "hue": [ { "manufacturer": "Royal Philips Electronics" diff --git a/tests/components/huawei_lte/test_config_flow.py b/tests/components/huawei_lte/test_config_flow.py index aafa6abd57f..a9f5034fcfe 100644 --- a/tests/components/huawei_lte/test_config_flow.py +++ b/tests/components/huawei_lte/test_config_flow.py @@ -10,6 +10,21 @@ from homeassistant import data_entry_flow from homeassistant.const import CONF_USERNAME, CONF_PASSWORD, CONF_URL from homeassistant.components.huawei_lte.const import DOMAIN from homeassistant.components.huawei_lte.config_flow import ConfigFlowHandler +from homeassistant.components.ssdp import ( + ATTR_HOST, + ATTR_MANUFACTURER, + ATTR_MANUFACTURERURL, + ATTR_MODEL_NAME, + ATTR_MODEL_NUMBER, + ATTR_NAME, + ATTR_PORT, + ATTR_PRESENTATIONURL, + ATTR_SERIAL, + ATTR_ST, + ATTR_UDN, + ATTR_UPNP_DEVICE_TYPE, +) + from tests.common import MockConfigEntry @@ -20,21 +35,26 @@ FIXTURE_USER_INPUT = { } -async def test_show_set_form(hass): - """Test that the setup form is served.""" +@pytest.fixture +def flow(hass): + """Get flow to test.""" flow = ConfigFlowHandler() flow.hass = hass + flow.context = {} + return flow + + +async def test_show_set_form(flow): + """Test that the setup form is served.""" result = await flow.async_step_user(user_input=None) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "user" -async def test_urlize_plain_host(hass, requests_mock): +async def test_urlize_plain_host(flow, requests_mock): """Test that plain host or IP gets converted to a URL.""" requests_mock.request(ANY, ANY, exc=ConnectionError()) - flow = ConfigFlowHandler() - flow.hass = hass host = "192.168.100.1" user_input = {**FIXTURE_USER_INPUT, CONF_URL: host} result = await flow.async_step_user(user_input=user_input) @@ -44,14 +64,12 @@ async def test_urlize_plain_host(hass, requests_mock): assert user_input[CONF_URL] == f"http://{host}/" -async def test_already_configured(hass): +async def test_already_configured(flow): """Test we reject already configured devices.""" MockConfigEntry( domain=DOMAIN, data=FIXTURE_USER_INPUT, title="Already configured" - ).add_to_hass(hass) + ).add_to_hass(flow.hass) - flow = ConfigFlowHandler() - flow.hass = hass # Tweak URL a bit to check that doesn't fail duplicate detection result = await flow.async_step_user( user_input={ @@ -64,12 +82,10 @@ async def test_already_configured(hass): assert result["reason"] == "already_configured" -async def test_connection_error(hass, requests_mock): +async def test_connection_error(flow, requests_mock): """Test we show user form on connection error.""" requests_mock.request(ANY, ANY, exc=ConnectionError()) - flow = ConfigFlowHandler() - flow.hass = hass result = await flow.async_step_user(user_input=FIXTURE_USER_INPUT) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM @@ -107,15 +123,13 @@ def login_requests_mock(requests_mock): (ResponseCodeEnum.ERROR_SYSTEM_UNKNOWN, {"base": "response_error"}), ), ) -async def test_login_error(hass, login_requests_mock, code, errors): +async def test_login_error(flow, login_requests_mock, code, errors): """Test we show user form with appropriate error on response failure.""" login_requests_mock.request( ANY, f"{FIXTURE_USER_INPUT[CONF_URL]}api/user/login", text=f"{code}", ) - flow = ConfigFlowHandler() - flow.hass = hass result = await flow.async_step_user(user_input=FIXTURE_USER_INPUT) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM @@ -123,18 +137,41 @@ async def test_login_error(hass, login_requests_mock, code, errors): assert result["errors"] == errors -async def test_success(hass, login_requests_mock): +async def test_success(flow, login_requests_mock): """Test successful flow provides entry creation data.""" login_requests_mock.request( ANY, f"{FIXTURE_USER_INPUT[CONF_URL]}api/user/login", text=f"OK", ) - flow = ConfigFlowHandler() - flow.hass = hass result = await flow.async_step_user(user_input=FIXTURE_USER_INPUT) assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY assert result["data"][CONF_URL] == FIXTURE_USER_INPUT[CONF_URL] assert result["data"][CONF_USERNAME] == FIXTURE_USER_INPUT[CONF_USERNAME] assert result["data"][CONF_PASSWORD] == FIXTURE_USER_INPUT[CONF_PASSWORD] + + +async def test_ssdp(flow): + """Test SSDP discovery initiates config properly.""" + url = "http://192.168.100.1/" + result = await flow.async_step_ssdp( + discovery_info={ + ATTR_ST: "upnp:rootdevice", + ATTR_PORT: 60957, + ATTR_HOST: "192.168.100.1", + ATTR_MANUFACTURER: "Huawei", + ATTR_MANUFACTURERURL: "http://www.huawei.com/", + ATTR_MODEL_NAME: "Huawei router", + ATTR_MODEL_NUMBER: "12345678", + ATTR_NAME: "Mobile Wi-Fi", + ATTR_PRESENTATIONURL: url, + ATTR_SERIAL: "00000000", + ATTR_UDN: "uuid:XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX", + ATTR_UPNP_DEVICE_TYPE: "urn:schemas-upnp-org:device:InternetGatewayDevice:1", + } + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + assert flow.context[CONF_URL] == url