Motion Blinds upgrade to local push (#44391)
* Motion Blinds upgrade to local push
This commit is contained in:
parent
769513d6a8
commit
82f9de31b1
10 changed files with 136 additions and 24 deletions
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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"]
|
||||||
}
|
}
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
):
|
):
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue