Motion Blinds upgrade to local push (#44391)

* Motion Blinds upgrade to local push
This commit is contained in:
starkillerOG 2020-12-24 00:15:11 +01:00 committed by GitHub
parent 769513d6a8
commit 82f9de31b1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 136 additions and 24 deletions

View file

@ -1,22 +1,29 @@
"""The motion_blinds component.""" """The motion_blinds component."""
from asyncio import TimeoutError as AsyncioTimeoutError import asyncio
from datetime import timedelta from datetime import timedelta
import logging import logging
from socket import timeout from socket import timeout
from motionblinds import MotionMulticast
from homeassistant import config_entries, core from homeassistant import config_entries, core
from homeassistant.const import CONF_API_KEY, CONF_HOST from homeassistant.const import CONF_API_KEY, CONF_HOST, EVENT_HOMEASSISTANT_STOP
from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .const import DOMAIN, KEY_COORDINATOR, KEY_GATEWAY, MANUFACTURER from .const import (
DOMAIN,
KEY_COORDINATOR,
KEY_GATEWAY,
KEY_MULTICAST_LISTENER,
MANUFACTURER,
MOTION_PLATFORMS,
)
from .gateway import ConnectMotionGateway from .gateway import ConnectMotionGateway
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
MOTION_PLATFORMS = ["cover", "sensor"]
async def async_setup(hass: core.HomeAssistant, config: dict): async def async_setup(hass: core.HomeAssistant, config: dict):
"""Set up the Motion Blinds component.""" """Set up the Motion Blinds component."""
@ -31,8 +38,23 @@ async def async_setup_entry(
host = entry.data[CONF_HOST] host = entry.data[CONF_HOST]
key = entry.data[CONF_API_KEY] key = entry.data[CONF_API_KEY]
# Create multicast Listener
if KEY_MULTICAST_LISTENER not in hass.data[DOMAIN]:
multicast = MotionMulticast()
hass.data[DOMAIN][KEY_MULTICAST_LISTENER] = multicast
# start listening for local pushes (only once)
await hass.async_add_executor_job(multicast.Start_listen)
# register stop callback to shutdown listening for local pushes
def stop_motion_multicast(event):
"""Stop multicast thread."""
_LOGGER.debug("Shutting down Motion Listener")
multicast.Stop_listen()
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_motion_multicast)
# Connect to motion gateway # Connect to motion gateway
connect_gateway_class = ConnectMotionGateway(hass) connect_gateway_class = ConnectMotionGateway(hass, multicast)
if not await connect_gateway_class.async_connect_gateway(host, key): if not await connect_gateway_class.async_connect_gateway(host, key):
raise ConfigEntryNotReady raise ConfigEntryNotReady
motion_gateway = connect_gateway_class.gateway_device motion_gateway = connect_gateway_class.gateway_device
@ -41,14 +63,19 @@ async def async_setup_entry(
"""Call all updates using one async_add_executor_job.""" """Call all updates using one async_add_executor_job."""
motion_gateway.Update() motion_gateway.Update()
for blind in motion_gateway.device_list.values(): for blind in motion_gateway.device_list.values():
blind.Update() try:
blind.Update()
except timeout:
# let the error be logged and handled by the motionblinds library
pass
async def async_update_data(): async def async_update_data():
"""Fetch data from the gateway and blinds.""" """Fetch data from the gateway and blinds."""
try: try:
await hass.async_add_executor_job(update_gateway) await hass.async_add_executor_job(update_gateway)
except timeout as socket_timeout: except timeout:
raise AsyncioTimeoutError from socket_timeout # let the error be logged and handled by the motionblinds library
pass
coordinator = DataUpdateCoordinator( coordinator = DataUpdateCoordinator(
hass, hass,
@ -57,7 +84,7 @@ async def async_setup_entry(
name=entry.title, name=entry.title,
update_method=async_update_data, update_method=async_update_data,
# Polling interval. Will only be polled if there are subscribers. # Polling interval. Will only be polled if there are subscribers.
update_interval=timedelta(seconds=10), update_interval=timedelta(seconds=600),
) )
# Fetch initial data so we have data when entities subscribe # Fetch initial data so we have data when entities subscribe
@ -91,11 +118,22 @@ async def async_unload_entry(
hass: core.HomeAssistant, config_entry: config_entries.ConfigEntry hass: core.HomeAssistant, config_entry: config_entries.ConfigEntry
): ):
"""Unload a config entry.""" """Unload a config entry."""
unload_ok = await hass.config_entries.async_forward_entry_unload( unload_ok = all(
config_entry, "cover" await asyncio.gather(
*[
hass.config_entries.async_forward_entry_unload(config_entry, component)
for component in MOTION_PLATFORMS
]
)
) )
if unload_ok: if unload_ok:
hass.data[DOMAIN].pop(config_entry.entry_id) hass.data[DOMAIN].pop(config_entry.entry_id)
if len(hass.data[DOMAIN]) == 1:
# 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)
return unload_ok return unload_ok

View file

@ -7,12 +7,11 @@ from homeassistant import config_entries
from homeassistant.const import CONF_API_KEY, CONF_HOST from homeassistant.const import CONF_API_KEY, CONF_HOST
# pylint: disable=unused-import # pylint: disable=unused-import
from .const import DOMAIN from .const import DEFAULT_GATEWAY_NAME, DOMAIN
from .gateway import ConnectMotionGateway from .gateway import ConnectMotionGateway
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
DEFAULT_GATEWAY_NAME = "Motion Gateway"
CONFIG_SCHEMA = vol.Schema( CONFIG_SCHEMA = vol.Schema(
{ {
@ -26,7 +25,7 @@ class MotionBlindsFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a Motion Blinds config flow.""" """Handle a Motion Blinds config flow."""
VERSION = 1 VERSION = 1
CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH
def __init__(self): def __init__(self):
"""Initialize the Motion Blinds flow.""" """Initialize the Motion Blinds flow."""
@ -48,7 +47,7 @@ class MotionBlindsFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
async def async_step_connect(self, user_input=None): async def async_step_connect(self, user_input=None):
"""Connect to the Motion Gateway.""" """Connect to the Motion Gateway."""
connect_gateway_class = ConnectMotionGateway(self.hass) connect_gateway_class = ConnectMotionGateway(self.hass, None)
if not await connect_gateway_class.async_connect_gateway(self.host, self.key): if not await connect_gateway_class.async_connect_gateway(self.host, self.key):
return self.async_abort(reason="connection_error") return self.async_abort(reason="connection_error")
motion_gateway = connect_gateway_class.gateway_device motion_gateway = connect_gateway_class.gateway_device

View file

@ -1,6 +1,10 @@
"""Constants for the Motion Blinds component.""" """Constants for the Motion Blinds component."""
DOMAIN = "motion_blinds" DOMAIN = "motion_blinds"
MANUFACTURER = "Motion, Coulisse B.V." MANUFACTURER = "Motion Blinds, Coulisse B.V."
DEFAULT_GATEWAY_NAME = "Motion Blinds Gateway"
MOTION_PLATFORMS = ["cover", "sensor"]
KEY_GATEWAY = "gateway" KEY_GATEWAY = "gateway"
KEY_COORDINATOR = "coordinator" KEY_COORDINATOR = "coordinator"
KEY_MULTICAST_LISTENER = "multicast_listener"

View file

@ -15,6 +15,7 @@ from homeassistant.components.cover import (
DEVICE_CLASS_SHUTTER, DEVICE_CLASS_SHUTTER,
CoverEntity, CoverEntity,
) )
from homeassistant.core import callback
from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, KEY_COORDINATOR, KEY_GATEWAY, MANUFACTURER from .const import DOMAIN, KEY_COORDINATOR, KEY_GATEWAY, MANUFACTURER
@ -125,6 +126,11 @@ class MotionPositionDevice(CoordinatorEntity, CoverEntity):
"""Return the name of the blind.""" """Return the name of the blind."""
return f"{self._blind.blind_type}-{self._blind.mac[12:]}" return f"{self._blind.blind_type}-{self._blind.mac[12:]}"
@property
def available(self):
"""Return True if entity is available."""
return self._blind.available
@property @property
def current_cover_position(self): def current_cover_position(self):
""" """
@ -146,6 +152,21 @@ class MotionPositionDevice(CoordinatorEntity, CoverEntity):
"""Return if the cover is closed or not.""" """Return if the cover is closed or not."""
return self._blind.position == 100 return self._blind.position == 100
@callback
def _push_callback(self):
"""Update entity state when a push has been received."""
self.schedule_update_ha_state(force_refresh=False)
async def async_added_to_hass(self):
"""Subscribe to multicast pushes."""
self._blind.Register_callback(self.unique_id, self._push_callback)
await super().async_added_to_hass()
async def async_will_remove_from_hass(self):
"""Unsubscribe when removed."""
self._blind.Remove_callback(self.unique_id)
await super().async_will_remove_from_hass()
def open_cover(self, **kwargs): def open_cover(self, **kwargs):
"""Open the cover.""" """Open the cover."""
self._blind.Open() self._blind.Open()

View file

@ -10,9 +10,10 @@ _LOGGER = logging.getLogger(__name__)
class ConnectMotionGateway: class ConnectMotionGateway:
"""Class to async connect to a Motion Gateway.""" """Class to async connect to a Motion Gateway."""
def __init__(self, hass): def __init__(self, hass, multicast):
"""Initialize the entity.""" """Initialize the entity."""
self._hass = hass self._hass = hass
self._multicast = multicast
self._gateway_device = None self._gateway_device = None
@property @property
@ -24,11 +25,15 @@ class ConnectMotionGateway:
"""Update all information of the gateway.""" """Update all information of the gateway."""
self.gateway_device.GetDeviceList() self.gateway_device.GetDeviceList()
self.gateway_device.Update() self.gateway_device.Update()
for blind in self.gateway_device.device_list.values():
blind.Update_from_cache()
async def async_connect_gateway(self, host, key): async def async_connect_gateway(self, host, key):
"""Connect to the Motion Gateway.""" """Connect to the Motion Gateway."""
_LOGGER.debug("Initializing with host %s (key %s...)", host, key[:3]) _LOGGER.debug("Initializing with host %s (key %s...)", host, key[:3])
self._gateway_device = MotionGateway(ip=host, key=key) self._gateway_device = MotionGateway(
ip=host, key=key, multicast=self._multicast
)
try: try:
# update device info and get the connected sub devices # update device info and get the connected sub devices
await self._hass.async_add_executor_job(self.update_gateway) await self._hass.async_add_executor_job(self.update_gateway)

View file

@ -3,6 +3,6 @@
"name": "Motion Blinds", "name": "Motion Blinds",
"config_flow": true, "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/motion_blinds", "documentation": "https://www.home-assistant.io/integrations/motion_blinds",
"requirements": ["motionblinds==0.1.6"], "requirements": ["motionblinds==0.4.7"],
"codeowners": ["@starkillerOG"] "codeowners": ["@starkillerOG"]
} }

View file

@ -9,6 +9,7 @@ from homeassistant.const import (
PERCENTAGE, PERCENTAGE,
SIGNAL_STRENGTH_DECIBELS_MILLIWATT, SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
) )
from homeassistant.core import callback
from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity import Entity
from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity
@ -71,6 +72,11 @@ class MotionBatterySensor(CoordinatorEntity, Entity):
"""Return the name of the blind battery sensor.""" """Return the name of the blind battery sensor."""
return f"{self._blind.blind_type}-battery-{self._blind.mac[12:]}" return f"{self._blind.blind_type}-battery-{self._blind.mac[12:]}"
@property
def available(self):
"""Return True if entity is available."""
return self._blind.available
@property @property
def unit_of_measurement(self): def unit_of_measurement(self):
"""Return the unit of measurement of this entity, if any.""" """Return the unit of measurement of this entity, if any."""
@ -91,6 +97,21 @@ class MotionBatterySensor(CoordinatorEntity, Entity):
"""Return device specific state attributes.""" """Return device specific state attributes."""
return {ATTR_BATTERY_VOLTAGE: self._blind.battery_voltage} return {ATTR_BATTERY_VOLTAGE: self._blind.battery_voltage}
@callback
def push_callback(self):
"""Update entity state when a push has been received."""
self.schedule_update_ha_state(force_refresh=False)
async def async_added_to_hass(self):
"""Subscribe to multicast pushes."""
self._blind.Register_callback(self.unique_id, self.push_callback)
await super().async_added_to_hass()
async def async_will_remove_from_hass(self):
"""Unsubscribe when removed."""
self._blind.Remove_callback(self.unique_id)
await super().async_will_remove_from_hass()
class MotionTDBUBatterySensor(MotionBatterySensor): class MotionTDBUBatterySensor(MotionBatterySensor):
""" """
@ -160,6 +181,11 @@ class MotionSignalStrengthSensor(CoordinatorEntity, Entity):
return "Motion gateway signal strength" return "Motion gateway signal strength"
return f"{self._device.blind_type} signal strength - {self._device.mac[12:]}" return f"{self._device.blind_type} signal strength - {self._device.mac[12:]}"
@property
def available(self):
"""Return True if entity is available."""
return self._device.available
@property @property
def unit_of_measurement(self): def unit_of_measurement(self):
"""Return the unit of measurement of this entity, if any.""" """Return the unit of measurement of this entity, if any."""
@ -179,3 +205,18 @@ class MotionSignalStrengthSensor(CoordinatorEntity, Entity):
def state(self): def state(self):
"""Return the state of the sensor.""" """Return the state of the sensor."""
return self._device.RSSI return self._device.RSSI
@callback
def push_callback(self):
"""Update entity state when a push has been received."""
self.schedule_update_ha_state(force_refresh=False)
async def async_added_to_hass(self):
"""Subscribe to multicast pushes."""
self._device.Register_callback(self.unique_id, self.push_callback)
await super().async_added_to_hass()
async def async_will_remove_from_hass(self):
"""Unsubscribe when removed."""
self._device.Remove_callback(self.unique_id)
await super().async_will_remove_from_hass()

View file

@ -952,7 +952,7 @@ minio==4.0.9
mitemp_bt==0.0.3 mitemp_bt==0.0.3
# homeassistant.components.motion_blinds # homeassistant.components.motion_blinds
motionblinds==0.1.6 motionblinds==0.4.7
# homeassistant.components.tts # homeassistant.components.tts
mutagen==1.45.1 mutagen==1.45.1

View file

@ -474,7 +474,7 @@ millheater==0.4.0
minio==4.0.9 minio==4.0.9
# homeassistant.components.motion_blinds # homeassistant.components.motion_blinds
motionblinds==0.1.6 motionblinds==0.4.7
# homeassistant.components.tts # homeassistant.components.tts
mutagen==1.45.1 mutagen==1.45.1

View file

@ -8,10 +8,11 @@ from homeassistant.components.motion_blinds.config_flow import DEFAULT_GATEWAY_N
from homeassistant.components.motion_blinds.const import DOMAIN from homeassistant.components.motion_blinds.const import DOMAIN
from homeassistant.const import CONF_API_KEY, CONF_HOST from homeassistant.const import CONF_API_KEY, CONF_HOST
from tests.async_mock import patch from tests.async_mock import Mock, patch
TEST_HOST = "1.2.3.4" TEST_HOST = "1.2.3.4"
TEST_API_KEY = "12ab345c-d67e-8f" TEST_API_KEY = "12ab345c-d67e-8f"
TEST_DEVICE_LIST = {"mac": Mock()}
@pytest.fixture(name="motion_blinds_connect", autouse=True) @pytest.fixture(name="motion_blinds_connect", autouse=True)
@ -23,6 +24,9 @@ def motion_blinds_connect_fixture():
), patch( ), patch(
"homeassistant.components.motion_blinds.gateway.MotionGateway.Update", "homeassistant.components.motion_blinds.gateway.MotionGateway.Update",
return_value=True, return_value=True,
), patch(
"homeassistant.components.motion_blinds.gateway.MotionGateway.device_list",
TEST_DEVICE_LIST,
), patch( ), patch(
"homeassistant.components.motion_blinds.async_setup_entry", return_value=True "homeassistant.components.motion_blinds.async_setup_entry", return_value=True
): ):