From 9bd2115a2010b5f45740b84202fa9d86796249bc Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Mon, 18 Oct 2021 23:34:51 +0200 Subject: [PATCH] Motion blinds add interface and wait_for_push options (#50067) Co-authored-by: Martin Hjelmare --- .../components/motion_blinds/__init__.py | 39 ++++- .../components/motion_blinds/config_flow.py | 113 ++++++++++++-- .../components/motion_blinds/const.py | 5 + .../components/motion_blinds/manifest.json | 1 + .../components/motion_blinds/strings.json | 17 ++- .../motion_blinds/translations/en.json | 17 ++- .../motion_blinds/test_config_flow.py | 141 ++++++++++++++++-- 7 files changed, 300 insertions(+), 33 deletions(-) diff --git a/homeassistant/components/motion_blinds/__init__.py b/homeassistant/components/motion_blinds/__init__.py index a4fb003b546..14bdeae817b 100644 --- a/homeassistant/components/motion_blinds/__init__.py +++ b/homeassistant/components/motion_blinds/__init__.py @@ -3,7 +3,7 @@ from datetime import timedelta import logging from socket import timeout -from motionblinds import MotionMulticast, ParseException +from motionblinds import AsyncMotionMulticast, ParseException from homeassistant import config_entries, core from homeassistant.const import CONF_API_KEY, CONF_HOST, EVENT_HOMEASSISTANT_STOP @@ -13,6 +13,10 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import ( ATTR_AVAILABLE, + CONF_INTERFACE, + CONF_WAIT_FOR_PUSH, + DEFAULT_INTERFACE, + DEFAULT_WAIT_FOR_PUSH, DOMAIN, KEY_COORDINATOR, KEY_GATEWAY, @@ -34,7 +38,7 @@ class DataUpdateCoordinatorMotionBlinds(DataUpdateCoordinator): self, hass, logger, - gateway, + coordinator_info, *, name, update_interval=None, @@ -49,7 +53,8 @@ class DataUpdateCoordinatorMotionBlinds(DataUpdateCoordinator): update_interval=update_interval, ) - self._gateway = gateway + self._gateway = coordinator_info[KEY_GATEWAY] + self._wait_for_push = coordinator_info[CONF_WAIT_FOR_PUSH] def update_gateway(self): """Call all updates using one async_add_executor_job.""" @@ -66,7 +71,10 @@ class DataUpdateCoordinatorMotionBlinds(DataUpdateCoordinator): for blind in self._gateway.device_list.values(): try: - blind.Update() + if self._wait_for_push: + blind.Update() + else: + blind.Update_trigger() except (timeout, ParseException): # let the error be logged and handled by the motionblinds library data[blind.mac] = {ATTR_AVAILABLE: False} @@ -95,13 +103,17 @@ async def async_setup_entry( hass.data.setdefault(DOMAIN, {}) host = entry.data[CONF_HOST] key = entry.data[CONF_API_KEY] + multicast_interface = entry.data.get(CONF_INTERFACE, DEFAULT_INTERFACE) + wait_for_push = entry.options.get(CONF_WAIT_FOR_PUSH, DEFAULT_WAIT_FOR_PUSH) + + entry.async_on_unload(entry.add_update_listener(update_listener)) # Create multicast Listener if KEY_MULTICAST_LISTENER not in hass.data[DOMAIN]: - multicast = MotionMulticast() + multicast = AsyncMotionMulticast(interface=multicast_interface) hass.data[DOMAIN][KEY_MULTICAST_LISTENER] = multicast # start listening for local pushes (only once) - await hass.async_add_executor_job(multicast.Start_listen) + await multicast.Start_listen() # register stop callback to shutdown listening for local pushes def stop_motion_multicast(event): @@ -117,11 +129,15 @@ async def async_setup_entry( if not await connect_gateway_class.async_connect_gateway(host, key): raise ConfigEntryNotReady motion_gateway = connect_gateway_class.gateway_device + coordinator_info = { + KEY_GATEWAY: motion_gateway, + CONF_WAIT_FOR_PUSH: wait_for_push, + } coordinator = DataUpdateCoordinatorMotionBlinds( hass, _LOGGER, - motion_gateway, + coordinator_info, # Name of the data. For logging purposes. name=entry.title, # Polling interval. Will only be polled if there are subscribers. @@ -172,6 +188,13 @@ async def async_unload_entry( # No motion gateways left, stop Motion multicast _LOGGER.debug("Shutting down Motion Listener") multicast = hass.data[DOMAIN].pop(KEY_MULTICAST_LISTENER) - await hass.async_add_executor_job(multicast.Stop_listen) + multicast.Stop_listen() return unload_ok + + +async def update_listener( + hass: core.HomeAssistant, config_entry: config_entries.ConfigEntry +) -> None: + """Handle options update.""" + await hass.config_entries.async_reload(config_entry.entry_id) diff --git a/homeassistant/components/motion_blinds/config_flow.py b/homeassistant/components/motion_blinds/config_flow.py index 796911cef6e..12d4a3440ec 100644 --- a/homeassistant/components/motion_blinds/config_flow.py +++ b/homeassistant/components/motion_blinds/config_flow.py @@ -1,11 +1,22 @@ """Config flow to configure Motion Blinds using their WLAN API.""" -from motionblinds import MotionDiscovery +from socket import gaierror + +from motionblinds import AsyncMotionMulticast, MotionDiscovery import voluptuous as vol from homeassistant import config_entries +from homeassistant.components import network from homeassistant.const import CONF_API_KEY, CONF_HOST +from homeassistant.core import callback -from .const import DEFAULT_GATEWAY_NAME, DOMAIN +from .const import ( + CONF_INTERFACE, + CONF_WAIT_FOR_PUSH, + DEFAULT_GATEWAY_NAME, + DEFAULT_INTERFACE, + DEFAULT_WAIT_FOR_PUSH, + DOMAIN, +) from .gateway import ConnectMotionGateway CONFIG_SCHEMA = vol.Schema( @@ -14,11 +25,34 @@ CONFIG_SCHEMA = vol.Schema( } ) -CONFIG_SETTINGS = vol.Schema( - { - vol.Required(CONF_API_KEY): vol.All(str, vol.Length(min=16, max=16)), - } -) + +class OptionsFlowHandler(config_entries.OptionsFlow): + """Options for the component.""" + + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: + """Init object.""" + self.config_entry = config_entry + + async def async_step_init(self, user_input=None): + """Manage the options.""" + errors = {} + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + settings_schema = vol.Schema( + { + vol.Optional( + CONF_WAIT_FOR_PUSH, + default=self.config_entry.options.get( + CONF_WAIT_FOR_PUSH, DEFAULT_WAIT_FOR_PUSH + ), + ): bool, + } + ) + + return self.async_show_form( + step_id="init", data_schema=settings_schema, errors=errors + ) class MotionBlindsFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): @@ -30,6 +64,13 @@ class MotionBlindsFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Initialize the Motion Blinds flow.""" self._host = None self._ips = [] + self._config_settings = None + + @staticmethod + @callback + def async_get_options_flow(config_entry) -> OptionsFlowHandler: + """Get the options flow.""" + return OptionsFlowHandler(config_entry) async def async_step_user(self, user_input=None): """Handle a flow initialized by the user.""" @@ -70,8 +111,24 @@ class MotionBlindsFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): async def async_step_connect(self, user_input=None): """Connect to the Motion Gateway.""" + 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) if not await connect_gateway_class.async_connect_gateway(self._host, key): @@ -85,7 +142,45 @@ class MotionBlindsFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return self.async_create_entry( title=DEFAULT_GATEWAY_NAME, - data={CONF_HOST: self._host, CONF_API_KEY: key}, + data={ + CONF_HOST: self._host, + CONF_API_KEY: key, + CONF_INTERFACE: multicast_interface, + }, ) - return self.async_show_form(step_id="connect", data_schema=CONFIG_SETTINGS) + (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] + 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/const.py b/homeassistant/components/motion_blinds/const.py index 52c6e39b096..fca6b694fad 100644 --- a/homeassistant/components/motion_blinds/const.py +++ b/homeassistant/components/motion_blinds/const.py @@ -5,6 +5,11 @@ DEFAULT_GATEWAY_NAME = "Motion Blinds Gateway" PLATFORMS = ["cover", "sensor"] +CONF_WAIT_FOR_PUSH = "wait_for_push" +CONF_INTERFACE = "interface" +DEFAULT_WAIT_FOR_PUSH = False +DEFAULT_INTERFACE = "any" + KEY_GATEWAY = "gateway" KEY_COORDINATOR = "coordinator" KEY_MULTICAST_LISTENER = "multicast_listener" diff --git a/homeassistant/components/motion_blinds/manifest.json b/homeassistant/components/motion_blinds/manifest.json index 346729925e9..7ff87c1b58b 100644 --- a/homeassistant/components/motion_blinds/manifest.json +++ b/homeassistant/components/motion_blinds/manifest.json @@ -4,6 +4,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/motion_blinds", "requirements": ["motionblinds==0.5.7"], + "dependencies": ["network"], "codeowners": ["@starkillerOG"], "iot_class": "local_push" } diff --git a/homeassistant/components/motion_blinds/strings.json b/homeassistant/components/motion_blinds/strings.json index 4511b316cd6..e5c86c2a45e 100644 --- a/homeassistant/components/motion_blinds/strings.json +++ b/homeassistant/components/motion_blinds/strings.json @@ -12,7 +12,8 @@ "title": "Motion Blinds", "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%]" + "api_key": "[%key:common::config_flow::data::api_key%]", + "interface": "The network interface to use" } }, "select": { @@ -24,13 +25,25 @@ } }, "error": { - "discovery_error": "Failed to discover a Motion Gateway" + "discovery_error": "Failed to discover a Motion Gateway", + "invalid_interface": "Invalid network interface" }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", "connection_error": "[%key:common::config_flow::error::cannot_connect%]" } + }, + "options": { + "step": { + "init": { + "title": "Motion Blinds", + "description": "Specify optional settings", + "data": { + "wait_for_push": "Wait for multicast push on update" + } + } + } } } diff --git a/homeassistant/components/motion_blinds/translations/en.json b/homeassistant/components/motion_blinds/translations/en.json index 3a968bc6491..233e019e255 100644 --- a/homeassistant/components/motion_blinds/translations/en.json +++ b/homeassistant/components/motion_blinds/translations/en.json @@ -6,13 +6,15 @@ "connection_error": "Failed to connect" }, "error": { - "discovery_error": "Failed to discover a Motion Gateway" + "discovery_error": "Failed to discover a Motion Gateway", + "invalid_interface": "Invalid network interface" }, "flow_title": "Motion Blinds", "step": { "connect": { "data": { - "api_key": "API Key" + "api_key": "API Key", + "interface": "The network interface to use" }, "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" @@ -33,5 +35,16 @@ "title": "Motion Blinds" } } + }, + "options": { + "step": { + "init": { + "title": "Motion Blinds", + "description": "Specify optional settings", + "data": { + "wait_for_push": "Wait for multicast push on update" + } + } + } } } \ No newline at end of file diff --git a/tests/components/motion_blinds/test_config_flow.py b/tests/components/motion_blinds/test_config_flow.py index 18592421249..b5e2f8fb717 100644 --- a/tests/components/motion_blinds/test_config_flow.py +++ b/tests/components/motion_blinds/test_config_flow.py @@ -4,13 +4,16 @@ from unittest.mock import Mock, patch import pytest -from homeassistant import config_entries +from homeassistant import config_entries, data_entry_flow +from homeassistant.components.motion_blinds import const from homeassistant.components.motion_blinds.config_flow import DEFAULT_GATEWAY_NAME -from homeassistant.components.motion_blinds.const import DOMAIN from homeassistant.const import CONF_API_KEY, CONF_HOST +from tests.common import MockConfigEntry + TEST_HOST = "1.2.3.4" TEST_HOST2 = "5.6.7.8" +TEST_HOST_HA = "9.10.11.12" TEST_API_KEY = "12ab345c-d67e-8f" TEST_MAC = "ab:cd:ef:gh" TEST_MAC2 = "ij:kl:mn:op" @@ -56,9 +59,13 @@ TEST_DISCOVERY_2 = { }, } +TEST_INTERFACES = [ + {"enabled": True, "default": True, "ipv4": [{"address": TEST_HOST_HA}]} +] + @pytest.fixture(name="motion_blinds_connect", autouse=True) -def motion_blinds_connect_fixture(): +def motion_blinds_connect_fixture(mock_get_source_ip): """Mock motion blinds connection and entry setup.""" with patch( "homeassistant.components.motion_blinds.gateway.MotionGateway.GetDeviceList", @@ -72,6 +79,15 @@ def motion_blinds_connect_fixture(): ), patch( "homeassistant.components.motion_blinds.config_flow.MotionDiscovery.discover", return_value=TEST_DISCOVERY_1, + ), patch( + "homeassistant.components.motion_blinds.config_flow.AsyncMotionMulticast.Start_listen", + return_value=True, + ), patch( + "homeassistant.components.motion_blinds.config_flow.AsyncMotionMulticast.Stop_listen", + return_value=True, + ), patch( + "homeassistant.components.motion_blinds.config_flow.network.async_get_adapters", + return_value=TEST_INTERFACES, ), patch( "homeassistant.components.motion_blinds.async_setup_entry", return_value=True ): @@ -81,7 +97,7 @@ def motion_blinds_connect_fixture(): async def test_config_flow_manual_host_success(hass): """Successful flow manually initialized by the user.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + const.DOMAIN, context={"source": config_entries.SOURCE_USER} ) assert result["type"] == "form" @@ -95,7 +111,7 @@ async def test_config_flow_manual_host_success(hass): assert result["type"] == "form" assert result["step_id"] == "connect" - assert result["errors"] is None + assert result["errors"] == {} result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -107,13 +123,14 @@ 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, } async def test_config_flow_discovery_1_success(hass): """Successful flow with 1 gateway discovered.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + const.DOMAIN, context={"source": config_entries.SOURCE_USER} ) assert result["type"] == "form" @@ -127,7 +144,7 @@ async def test_config_flow_discovery_1_success(hass): assert result["type"] == "form" assert result["step_id"] == "connect" - assert result["errors"] is None + assert result["errors"] == {} result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -139,13 +156,14 @@ async def test_config_flow_discovery_1_success(hass): assert result["data"] == { CONF_HOST: TEST_HOST, CONF_API_KEY: TEST_API_KEY, + const.CONF_INTERFACE: TEST_HOST_HA, } async def test_config_flow_discovery_2_success(hass): """Successful flow with 2 gateway discovered.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + const.DOMAIN, context={"source": config_entries.SOURCE_USER} ) assert result["type"] == "form" @@ -176,7 +194,7 @@ async def test_config_flow_discovery_2_success(hass): assert result["type"] == "form" assert result["step_id"] == "connect" - assert result["errors"] is None + assert result["errors"] == {} result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -188,13 +206,14 @@ async def test_config_flow_discovery_2_success(hass): assert result["data"] == { CONF_HOST: TEST_HOST2, CONF_API_KEY: TEST_API_KEY, + const.CONF_INTERFACE: TEST_HOST_HA, } async def test_config_flow_connection_error(hass): """Failed flow manually initialized by the user with connection timeout.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + const.DOMAIN, context={"source": config_entries.SOURCE_USER} ) assert result["type"] == "form" @@ -208,7 +227,7 @@ async def test_config_flow_connection_error(hass): assert result["type"] == "form" assert result["step_id"] == "connect" - assert result["errors"] is None + assert result["errors"] == {} with patch( "homeassistant.components.motion_blinds.gateway.MotionGateway.GetDeviceList", @@ -226,7 +245,7 @@ async def test_config_flow_connection_error(hass): async def test_config_flow_discovery_fail(hass): """Failed flow with no gateways discovered.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + const.DOMAIN, context={"source": config_entries.SOURCE_USER} ) assert result["type"] == "form" @@ -245,3 +264,101 @@ async def test_config_flow_discovery_fail(hass): assert result["type"] == "form" assert result["step_id"] == "user" 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( + 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"] == {} + + with patch( + "homeassistant.components.motion_blinds.config_flow.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}, + ) + + assert result["type"] == "form" + assert result["step_id"] == "connect" + assert result["errors"] == {const.CONF_INTERFACE: "invalid_interface"} + + +async def test_options_flow(hass): + """Test specifying non default settings using options flow.""" + config_entry = MockConfigEntry( + domain=const.DOMAIN, + unique_id=TEST_MAC, + data={ + CONF_HOST: TEST_HOST, + CONF_API_KEY: TEST_API_KEY, + }, + title=DEFAULT_GATEWAY_NAME, + ) + config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={const.CONF_WAIT_FOR_PUSH: False}, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert config_entry.options == { + const.CONF_WAIT_FOR_PUSH: False, + }