diff --git a/homeassistant/components/motion_blinds/__init__.py b/homeassistant/components/motion_blinds/__init__.py index 72929e1ecb7..95acd6a0c15 100644 --- a/homeassistant/components/motion_blinds/__init__.py +++ b/homeassistant/components/motion_blinds/__init__.py @@ -1,22 +1,29 @@ """The motion_blinds component.""" -from asyncio import TimeoutError as AsyncioTimeoutError +import asyncio from datetime import timedelta import logging from socket import timeout +from motionblinds import MotionMulticast + 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.helpers import device_registry as dr 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 _LOGGER = logging.getLogger(__name__) -MOTION_PLATFORMS = ["cover", "sensor"] - async def async_setup(hass: core.HomeAssistant, config: dict): """Set up the Motion Blinds component.""" @@ -31,8 +38,23 @@ async def async_setup_entry( host = entry.data[CONF_HOST] 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_gateway_class = ConnectMotionGateway(hass) + connect_gateway_class = ConnectMotionGateway(hass, multicast) if not await connect_gateway_class.async_connect_gateway(host, key): raise ConfigEntryNotReady 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.""" motion_gateway.Update() 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(): """Fetch data from the gateway and blinds.""" try: await hass.async_add_executor_job(update_gateway) - except timeout as socket_timeout: - raise AsyncioTimeoutError from socket_timeout + except timeout: + # let the error be logged and handled by the motionblinds library + pass coordinator = DataUpdateCoordinator( hass, @@ -57,7 +84,7 @@ async def async_setup_entry( name=entry.title, update_method=async_update_data, # 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 @@ -91,11 +118,22 @@ async def async_unload_entry( hass: core.HomeAssistant, config_entry: config_entries.ConfigEntry ): """Unload a config entry.""" - unload_ok = await hass.config_entries.async_forward_entry_unload( - config_entry, "cover" + unload_ok = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(config_entry, component) + for component in MOTION_PLATFORMS + ] + ) ) if unload_ok: 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 diff --git a/homeassistant/components/motion_blinds/config_flow.py b/homeassistant/components/motion_blinds/config_flow.py index fbee7d1b439..497f11760fe 100644 --- a/homeassistant/components/motion_blinds/config_flow.py +++ b/homeassistant/components/motion_blinds/config_flow.py @@ -7,12 +7,11 @@ from homeassistant import config_entries from homeassistant.const import CONF_API_KEY, CONF_HOST # pylint: disable=unused-import -from .const import DOMAIN +from .const import DEFAULT_GATEWAY_NAME, DOMAIN from .gateway import ConnectMotionGateway _LOGGER = logging.getLogger(__name__) -DEFAULT_GATEWAY_NAME = "Motion Gateway" CONFIG_SCHEMA = vol.Schema( { @@ -26,7 +25,7 @@ class MotionBlindsFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Handle a Motion Blinds config flow.""" VERSION = 1 - CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH def __init__(self): """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): """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): return self.async_abort(reason="connection_error") motion_gateway = connect_gateway_class.gateway_device diff --git a/homeassistant/components/motion_blinds/const.py b/homeassistant/components/motion_blinds/const.py index c80c8f881cd..e5a84041d35 100644 --- a/homeassistant/components/motion_blinds/const.py +++ b/homeassistant/components/motion_blinds/const.py @@ -1,6 +1,10 @@ """Constants for the Motion Blinds component.""" 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_COORDINATOR = "coordinator" +KEY_MULTICAST_LISTENER = "multicast_listener" diff --git a/homeassistant/components/motion_blinds/cover.py b/homeassistant/components/motion_blinds/cover.py index 4273be3f435..c1895aa5665 100644 --- a/homeassistant/components/motion_blinds/cover.py +++ b/homeassistant/components/motion_blinds/cover.py @@ -15,6 +15,7 @@ from homeassistant.components.cover import ( DEVICE_CLASS_SHUTTER, CoverEntity, ) +from homeassistant.core import callback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN, KEY_COORDINATOR, KEY_GATEWAY, MANUFACTURER @@ -125,6 +126,11 @@ class MotionPositionDevice(CoordinatorEntity, CoverEntity): """Return the name of the blind.""" 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 def current_cover_position(self): """ @@ -146,6 +152,21 @@ class MotionPositionDevice(CoordinatorEntity, CoverEntity): """Return if the cover is closed or not.""" 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): """Open the cover.""" self._blind.Open() diff --git a/homeassistant/components/motion_blinds/gateway.py b/homeassistant/components/motion_blinds/gateway.py index e7e665d65f9..14dd36ce5b0 100644 --- a/homeassistant/components/motion_blinds/gateway.py +++ b/homeassistant/components/motion_blinds/gateway.py @@ -10,9 +10,10 @@ _LOGGER = logging.getLogger(__name__) class ConnectMotionGateway: """Class to async connect to a Motion Gateway.""" - def __init__(self, hass): + def __init__(self, hass, multicast): """Initialize the entity.""" self._hass = hass + self._multicast = multicast self._gateway_device = None @property @@ -24,11 +25,15 @@ class ConnectMotionGateway: """Update all information of the gateway.""" self.gateway_device.GetDeviceList() 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): """Connect to the Motion Gateway.""" _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: # update device info and get the connected sub devices await self._hass.async_add_executor_job(self.update_gateway) diff --git a/homeassistant/components/motion_blinds/manifest.json b/homeassistant/components/motion_blinds/manifest.json index 84cf711ac97..ce781266a6e 100644 --- a/homeassistant/components/motion_blinds/manifest.json +++ b/homeassistant/components/motion_blinds/manifest.json @@ -3,6 +3,6 @@ "name": "Motion Blinds", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/motion_blinds", - "requirements": ["motionblinds==0.1.6"], + "requirements": ["motionblinds==0.4.7"], "codeowners": ["@starkillerOG"] -} \ No newline at end of file +} diff --git a/homeassistant/components/motion_blinds/sensor.py b/homeassistant/components/motion_blinds/sensor.py index 81d555806ed..0c31ca070cf 100644 --- a/homeassistant/components/motion_blinds/sensor.py +++ b/homeassistant/components/motion_blinds/sensor.py @@ -9,6 +9,7 @@ from homeassistant.const import ( PERCENTAGE, SIGNAL_STRENGTH_DECIBELS_MILLIWATT, ) +from homeassistant.core import callback from homeassistant.helpers.entity import Entity from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -71,6 +72,11 @@ class MotionBatterySensor(CoordinatorEntity, Entity): """Return the name of the blind battery sensor.""" 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 def unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" @@ -91,6 +97,21 @@ class MotionBatterySensor(CoordinatorEntity, Entity): """Return device specific state attributes.""" 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): """ @@ -160,6 +181,11 @@ class MotionSignalStrengthSensor(CoordinatorEntity, Entity): return "Motion gateway signal strength" 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 def unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" @@ -179,3 +205,18 @@ class MotionSignalStrengthSensor(CoordinatorEntity, Entity): def state(self): """Return the state of the sensor.""" 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() diff --git a/requirements_all.txt b/requirements_all.txt index 1e3c34e1912..68e2c5187d8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -952,7 +952,7 @@ minio==4.0.9 mitemp_bt==0.0.3 # homeassistant.components.motion_blinds -motionblinds==0.1.6 +motionblinds==0.4.7 # homeassistant.components.tts mutagen==1.45.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index dcdd3df923f..7f9c7134fdc 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -474,7 +474,7 @@ millheater==0.4.0 minio==4.0.9 # homeassistant.components.motion_blinds -motionblinds==0.1.6 +motionblinds==0.4.7 # homeassistant.components.tts mutagen==1.45.1 diff --git a/tests/components/motion_blinds/test_config_flow.py b/tests/components/motion_blinds/test_config_flow.py index faa3e7115b8..4514beda8c0 100644 --- a/tests/components/motion_blinds/test_config_flow.py +++ b/tests/components/motion_blinds/test_config_flow.py @@ -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.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_API_KEY = "12ab345c-d67e-8f" +TEST_DEVICE_LIST = {"mac": Mock()} @pytest.fixture(name="motion_blinds_connect", autouse=True) @@ -23,6 +24,9 @@ def motion_blinds_connect_fixture(): ), patch( "homeassistant.components.motion_blinds.gateway.MotionGateway.Update", return_value=True, + ), patch( + "homeassistant.components.motion_blinds.gateway.MotionGateway.device_list", + TEST_DEVICE_LIST, ), patch( "homeassistant.components.motion_blinds.async_setup_entry", return_value=True ):