Motion blinds add interface and wait_for_push options (#50067)

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
This commit is contained in:
starkillerOG 2021-10-18 23:34:51 +02:00 committed by GitHub
parent 174eaefe61
commit 9bd2115a20
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 300 additions and 33 deletions

View file

@ -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)

View file

@ -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)

View file

@ -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"

View file

@ -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"
}

View file

@ -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"
}
}
}
}
}

View file

@ -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"
}
}
}
}
}

View file

@ -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,
}