From 4dade9668ac3b9a50b0320ca6fc2b30de61db85e Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Mon, 4 Apr 2022 22:55:36 +0200 Subject: [PATCH] Motion Blinds auto interface (#68852) Co-authored-by: J. Nick Koston --- .../components/motion_blinds/__init__.py | 15 +++- .../components/motion_blinds/config_flow.py | 57 +++--------- .../components/motion_blinds/gateway.py | 85 +++++++++++++++++- .../components/motion_blinds/manifest.json | 2 +- .../components/motion_blinds/strings.json | 6 +- .../motion_blinds/translations/en.json | 6 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../motion_blinds/test_config_flow.py | 90 ++++++++----------- 9 files changed, 151 insertions(+), 114 deletions(-) diff --git a/homeassistant/components/motion_blinds/__init__.py b/homeassistant/components/motion_blinds/__init__.py index 508851e6dad..63c80d33dba 100644 --- a/homeassistant/components/motion_blinds/__init__.py +++ b/homeassistant/components/motion_blinds/__init__.py @@ -113,9 +113,22 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.async_on_unload(entry.add_update_listener(update_listener)) + # check multicast interface + check_multicast_class = ConnectMotionGateway(hass, interface=multicast_interface) + working_interface = await check_multicast_class.async_check_interface(host, key) + if working_interface != multicast_interface: + data = {**entry.data, CONF_INTERFACE: working_interface} + hass.config_entries.async_update_entry(entry, data=data) + _LOGGER.debug( + "Motion Blinds interface updated from %s to %s, " + "this should only occur after a network change", + multicast_interface, + working_interface, + ) + # Create multicast Listener if KEY_MULTICAST_LISTENER not in hass.data[DOMAIN]: - multicast = AsyncMotionMulticast(interface=multicast_interface) + multicast = AsyncMotionMulticast(interface=working_interface) hass.data[DOMAIN][KEY_MULTICAST_LISTENER] = multicast # start listening for local pushes (only once) await multicast.Start_listen() diff --git a/homeassistant/components/motion_blinds/config_flow.py b/homeassistant/components/motion_blinds/config_flow.py index a956289d72e..e40f22296cb 100644 --- a/homeassistant/components/motion_blinds/config_flow.py +++ b/homeassistant/components/motion_blinds/config_flow.py @@ -1,11 +1,9 @@ """Config flow to configure Motion Blinds using their WLAN API.""" -from socket import gaierror - -from motionblinds import AsyncMotionMulticast, MotionDiscovery +from motionblinds import MotionDiscovery import voluptuous as vol from homeassistant import config_entries -from homeassistant.components import dhcp, network +from homeassistant.components import dhcp from homeassistant.const import CONF_API_KEY, CONF_HOST from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult @@ -131,27 +129,20 @@ class MotionBlindsFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): errors = {} if user_input is not None: key = user_input[CONF_API_KEY] - multicast_interface = user_input[CONF_INTERFACE] - # check socket interface - if multicast_interface != DEFAULT_INTERFACE: - motion_multicast = AsyncMotionMulticast(interface=multicast_interface) - try: - await motion_multicast.Start_listen() - motion_multicast.Stop_listen() - except gaierror: - errors[CONF_INTERFACE] = "invalid_interface" - return self.async_show_form( - step_id="connect", - data_schema=self._config_settings, - errors=errors, - ) - - connect_gateway_class = ConnectMotionGateway(self.hass, multicast=None) + connect_gateway_class = ConnectMotionGateway(self.hass) if not await connect_gateway_class.async_connect_gateway(self._host, key): return self.async_abort(reason="connection_error") motion_gateway = connect_gateway_class.gateway_device + # check socket interface + check_multicast_class = ConnectMotionGateway( + self.hass, interface=DEFAULT_INTERFACE + ) + multicast_interface = await check_multicast_class.async_check_interface( + self._host, key + ) + mac_address = motion_gateway.mac await self.async_set_unique_id(mac_address, raise_on_progress=False) @@ -172,38 +163,12 @@ class MotionBlindsFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): }, ) - (interfaces, default_interface) = await self.async_get_interfaces() - self._config_settings = vol.Schema( { vol.Required(CONF_API_KEY): vol.All(str, vol.Length(min=16, max=16)), - vol.Optional(CONF_INTERFACE, default=default_interface): vol.In( - interfaces - ), } ) return self.async_show_form( step_id="connect", data_schema=self._config_settings, errors=errors ) - - async def async_get_interfaces(self): - """Get list of interface to use.""" - interfaces = [DEFAULT_INTERFACE, "0.0.0.0"] - enabled_interfaces = [] - default_interface = DEFAULT_INTERFACE - - adapters = await network.async_get_adapters(self.hass) - for adapter in adapters: - if ipv4s := adapter["ipv4"]: - ip4 = ipv4s[0]["address"] - interfaces.append(ip4) - if adapter["enabled"]: - enabled_interfaces.append(ip4) - if adapter["default"]: - default_interface = ip4 - - if len(enabled_interfaces) == 1: - default_interface = enabled_interfaces[0] - - return (interfaces, default_interface) diff --git a/homeassistant/components/motion_blinds/gateway.py b/homeassistant/components/motion_blinds/gateway.py index 6f8032e5a65..1775f7deb7d 100644 --- a/homeassistant/components/motion_blinds/gateway.py +++ b/homeassistant/components/motion_blinds/gateway.py @@ -1,8 +1,13 @@ """Code to handle a Motion Gateway.""" +import contextlib import logging import socket -from motionblinds import MotionGateway +from motionblinds import AsyncMotionMulticast, MotionGateway + +from homeassistant.components import network + +from .const import DEFAULT_INTERFACE _LOGGER = logging.getLogger(__name__) @@ -10,11 +15,12 @@ _LOGGER = logging.getLogger(__name__) class ConnectMotionGateway: """Class to async connect to a Motion Gateway.""" - def __init__(self, hass, multicast): + def __init__(self, hass, multicast=None, interface=None): """Initialize the entity.""" self._hass = hass self._multicast = multicast self._gateway_device = None + self._interface = interface @property def gateway_device(self): @@ -48,3 +54,78 @@ class ConnectMotionGateway: self.gateway_device.protocol, ) return True + + def check_interface(self): + """Check if the current interface supports multicast.""" + with contextlib.suppress(socket.timeout): + return self.gateway_device.Check_gateway_multicast() + return False + + async def async_get_interfaces(self): + """Get list of interface to use.""" + interfaces = [DEFAULT_INTERFACE, "0.0.0.0"] + enabled_interfaces = [] + default_interface = DEFAULT_INTERFACE + + adapters = await network.async_get_adapters(self._hass) + for adapter in adapters: + if ipv4s := adapter["ipv4"]: + ip4 = ipv4s[0]["address"] + interfaces.append(ip4) + if adapter["enabled"]: + enabled_interfaces.append(ip4) + if adapter["default"]: + default_interface = ip4 + + if len(enabled_interfaces) == 1: + default_interface = enabled_interfaces[0] + interfaces.remove(default_interface) + interfaces.insert(0, default_interface) + + if self._interface is not None: + interfaces.remove(self._interface) + interfaces.insert(0, self._interface) + + return interfaces + + async def async_check_interface(self, host, key): + """Connect to the Motion Gateway.""" + interfaces = await self.async_get_interfaces() + for interface in interfaces: + _LOGGER.debug( + "Checking Motion Blinds interface '%s' with host %s", interface, host + ) + # initialize multicast listener + check_multicast = AsyncMotionMulticast(interface=interface) + try: + await check_multicast.Start_listen() + except socket.gaierror: + continue + + # trigger test multicast + self._gateway_device = MotionGateway( + ip=host, key=key, multicast=check_multicast + ) + result = await self._hass.async_add_executor_job(self.check_interface) + + # close multicast listener again + try: + check_multicast.Stop_listen() + except socket.gaierror: + continue + + if result: + # successfully received multicast + _LOGGER.debug( + "Success using Motion Blinds interface '%s' with host %s", + interface, + host, + ) + return interface + + _LOGGER.error( + "Could not find working interface for Motion Blinds host %s, using interface '%s'", + host, + self._interface, + ) + return self._interface diff --git a/homeassistant/components/motion_blinds/manifest.json b/homeassistant/components/motion_blinds/manifest.json index 7a8a8a3fadf..703af31627b 100644 --- a/homeassistant/components/motion_blinds/manifest.json +++ b/homeassistant/components/motion_blinds/manifest.json @@ -3,7 +3,7 @@ "name": "Motion Blinds", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/motion_blinds", - "requirements": ["motionblinds==0.6.2"], + "requirements": ["motionblinds==0.6.4"], "dependencies": ["network"], "dhcp": [ { "registered_devices": true }, diff --git a/homeassistant/components/motion_blinds/strings.json b/homeassistant/components/motion_blinds/strings.json index c62c6dc2873..13a4d117344 100644 --- a/homeassistant/components/motion_blinds/strings.json +++ b/homeassistant/components/motion_blinds/strings.json @@ -11,8 +11,7 @@ "connect": { "description": "You will need the 16 character API Key, see https://www.home-assistant.io/integrations/motion_blinds/#retrieving-the-key for instructions", "data": { - "api_key": "[%key:common::config_flow::data::api_key%]", - "interface": "The network interface to use" + "api_key": "[%key:common::config_flow::data::api_key%]" } }, "select": { @@ -24,8 +23,7 @@ } }, "error": { - "discovery_error": "Failed to discover a Motion Gateway", - "invalid_interface": "Invalid network interface" + "discovery_error": "Failed to discover a Motion Gateway" }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%], connection settings are updated", diff --git a/homeassistant/components/motion_blinds/translations/en.json b/homeassistant/components/motion_blinds/translations/en.json index 92931ee27ab..4033a8f2016 100644 --- a/homeassistant/components/motion_blinds/translations/en.json +++ b/homeassistant/components/motion_blinds/translations/en.json @@ -6,15 +6,13 @@ "connection_error": "Failed to connect" }, "error": { - "discovery_error": "Failed to discover a Motion Gateway", - "invalid_interface": "Invalid network interface" + "discovery_error": "Failed to discover a Motion Gateway" }, "flow_title": "{short_mac} ({ip_address})", "step": { "connect": { "data": { - "api_key": "API Key", - "interface": "The network interface to use" + "api_key": "API Key" }, "description": "You will need the 16 character API Key, see https://www.home-assistant.io/integrations/motion_blinds/#retrieving-the-key for instructions", "title": "Motion Blinds" diff --git a/requirements_all.txt b/requirements_all.txt index 9a4bea8def7..e1867f2f7a2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1020,7 +1020,7 @@ mitemp_bt==0.0.5 moehlenhoff-alpha2==1.1.2 # homeassistant.components.motion_blinds -motionblinds==0.6.2 +motionblinds==0.6.4 # homeassistant.components.motioneye motioneye-client==0.3.12 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9c6ba62b9ac..fd0ee86d308 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -688,7 +688,7 @@ minio==5.0.10 moehlenhoff-alpha2==1.1.2 # homeassistant.components.motion_blinds -motionblinds==0.6.2 +motionblinds==0.6.4 # homeassistant.components.motioneye motioneye-client==0.3.12 diff --git a/tests/components/motion_blinds/test_config_flow.py b/tests/components/motion_blinds/test_config_flow.py index fce86f5f343..57ad3c20779 100644 --- a/tests/components/motion_blinds/test_config_flow.py +++ b/tests/components/motion_blinds/test_config_flow.py @@ -76,6 +76,9 @@ def motion_blinds_connect_fixture(mock_get_source_ip): ), patch( "homeassistant.components.motion_blinds.gateway.MotionGateway.Update", return_value=True, + ), patch( + "homeassistant.components.motion_blinds.gateway.MotionGateway.Check_gateway_multicast", + return_value=True, ), patch( "homeassistant.components.motion_blinds.gateway.MotionGateway.device_list", TEST_DEVICE_LIST, @@ -86,13 +89,13 @@ def motion_blinds_connect_fixture(mock_get_source_ip): "homeassistant.components.motion_blinds.config_flow.MotionDiscovery.discover", return_value=TEST_DISCOVERY_1, ), patch( - "homeassistant.components.motion_blinds.config_flow.AsyncMotionMulticast.Start_listen", + "homeassistant.components.motion_blinds.gateway.AsyncMotionMulticast.Start_listen", return_value=True, ), patch( - "homeassistant.components.motion_blinds.config_flow.AsyncMotionMulticast.Stop_listen", + "homeassistant.components.motion_blinds.gateway.AsyncMotionMulticast.Stop_listen", return_value=True, ), patch( - "homeassistant.components.motion_blinds.config_flow.network.async_get_adapters", + "homeassistant.components.motion_blinds.gateway.network.async_get_adapters", return_value=TEST_INTERFACES, ), patch( "homeassistant.components.motion_blinds.async_setup_entry", return_value=True @@ -129,7 +132,7 @@ async def test_config_flow_manual_host_success(hass): assert result["data"] == { CONF_HOST: TEST_HOST, CONF_API_KEY: TEST_API_KEY, - const.CONF_INTERFACE: TEST_HOST_HA, + const.CONF_INTERFACE: TEST_HOST_ANY, } @@ -152,17 +155,21 @@ async def test_config_flow_discovery_1_success(hass): assert result["step_id"] == "connect" assert result["errors"] == {} - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_API_KEY: TEST_API_KEY}, - ) + with patch( + "homeassistant.components.motion_blinds.gateway.AsyncMotionMulticast.Stop_listen", + side_effect=socket.gaierror, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: TEST_API_KEY}, + ) assert result["type"] == "create_entry" assert result["title"] == DEFAULT_GATEWAY_NAME assert result["data"] == { CONF_HOST: TEST_HOST, CONF_API_KEY: TEST_API_KEY, - const.CONF_INTERFACE: TEST_HOST_HA, + const.CONF_INTERFACE: TEST_HOST_ANY, } @@ -202,17 +209,21 @@ async def test_config_flow_discovery_2_success(hass): assert result["step_id"] == "connect" assert result["errors"] == {} - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_API_KEY: TEST_API_KEY}, - ) + with patch( + "homeassistant.components.motion_blinds.gateway.MotionGateway.Check_gateway_multicast", + side_effect=socket.timeout, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: TEST_API_KEY}, + ) assert result["type"] == "create_entry" assert result["title"] == DEFAULT_GATEWAY_NAME assert result["data"] == { CONF_HOST: TEST_HOST2, CONF_API_KEY: TEST_API_KEY, - const.CONF_INTERFACE: TEST_HOST_HA, + const.CONF_INTERFACE: TEST_HOST_ANY, } @@ -272,39 +283,6 @@ async def test_config_flow_discovery_fail(hass): assert result["errors"] == {"base": "discovery_error"} -async def test_config_flow_interface(hass): - """Successful flow manually initialized by the user with interface specified.""" - result = await hass.config_entries.flow.async_init( - const.DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - assert result["type"] == "form" - assert result["step_id"] == "user" - assert result["errors"] == {} - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_HOST: TEST_HOST}, - ) - - assert result["type"] == "form" - assert result["step_id"] == "connect" - assert result["errors"] == {} - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_API_KEY: TEST_API_KEY, const.CONF_INTERFACE: TEST_HOST_HA}, - ) - - assert result["type"] == "create_entry" - assert result["title"] == DEFAULT_GATEWAY_NAME - assert result["data"] == { - CONF_HOST: TEST_HOST, - CONF_API_KEY: TEST_API_KEY, - const.CONF_INTERFACE: TEST_HOST_HA, - } - - async def test_config_flow_invalid_interface(hass): """Failed flow manually initialized by the user with invalid interface.""" result = await hass.config_entries.flow.async_init( @@ -325,17 +303,21 @@ async def test_config_flow_invalid_interface(hass): assert result["errors"] == {} with patch( - "homeassistant.components.motion_blinds.config_flow.AsyncMotionMulticast.Start_listen", + "homeassistant.components.motion_blinds.gateway.AsyncMotionMulticast.Start_listen", side_effect=socket.gaierror, ): result = await hass.config_entries.flow.async_configure( result["flow_id"], - {CONF_API_KEY: TEST_API_KEY, const.CONF_INTERFACE: TEST_HOST_HA}, + {CONF_API_KEY: TEST_API_KEY}, ) - assert result["type"] == "form" - assert result["step_id"] == "connect" - assert result["errors"] == {const.CONF_INTERFACE: "invalid_interface"} + assert result["type"] == "create_entry" + assert result["title"] == DEFAULT_GATEWAY_NAME + assert result["data"] == { + CONF_HOST: TEST_HOST, + CONF_API_KEY: TEST_API_KEY, + const.CONF_INTERFACE: TEST_HOST_ANY, + } async def test_dhcp_flow(hass): @@ -364,7 +346,7 @@ async def test_dhcp_flow(hass): assert result["data"] == { CONF_HOST: TEST_HOST, CONF_API_KEY: TEST_API_KEY, - const.CONF_INTERFACE: TEST_HOST_HA, + const.CONF_INTERFACE: TEST_HOST_ANY, } @@ -433,7 +415,7 @@ async def test_change_connection_settings(hass): result = await hass.config_entries.flow.async_configure( result["flow_id"], - {CONF_API_KEY: TEST_API_KEY2, const.CONF_INTERFACE: TEST_HOST_ANY}, + {CONF_API_KEY: TEST_API_KEY2}, ) assert result["type"] == "abort"