From d912e91e810d5b985766cb91978643467a7ee5c9 Mon Sep 17 00:00:00 2001 From: Eric Severance Date: Thu, 24 Dec 2020 03:25:14 -0800 Subject: [PATCH] Periodically attempt to discover new wemo devices (#44361) * Periodically attempt to discover new wemo devices * Set self._stop=None after stopping the periodic discovery task * Use the async_fire_time_changed test helper to simplify test_discovery * Stop the pywemo registry outside of the async loop * Add a comment describing why async_fire_time_changed is used --- homeassistant/components/wemo/__init__.py | 107 ++++++++++++++++------ tests/components/wemo/conftest.py | 4 + tests/components/wemo/test_init.py | 56 ++++++++++- 3 files changed, 137 insertions(+), 30 deletions(-) diff --git a/homeassistant/components/wemo/__init__.py b/homeassistant/components/wemo/__init__.py index d9d6a16ebf7..32913c37224 100644 --- a/homeassistant/components/wemo/__init__.py +++ b/homeassistant/components/wemo/__init__.py @@ -11,9 +11,12 @@ from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAI from homeassistant.components.fan import DOMAIN as FAN_DOMAIN from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.config_entries import ConfigEntry from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.event import async_call_later from .const import DOMAIN @@ -90,7 +93,7 @@ async def async_setup(hass, config): return True -async def async_setup_entry(hass, entry): +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): """Set up a wemo config entry.""" config = hass.data[DOMAIN].pop("config") @@ -98,14 +101,16 @@ async def async_setup_entry(hass, entry): registry = hass.data[DOMAIN]["registry"] = pywemo.SubscriptionRegistry() await hass.async_add_executor_job(registry.start) - def stop_wemo(event): + wemo_dispatcher = WemoDispatcher(entry) + wemo_discovery = WemoDiscovery(hass, wemo_dispatcher) + + async def async_stop_wemo(event): """Shutdown Wemo subscriptions and subscription thread on exit.""" _LOGGER.debug("Shutting down WeMo event subscriptions") - registry.stop() + await hass.async_add_executor_job(registry.stop) + wemo_discovery.async_stop_discovery() - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_wemo) - - devices = {} + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, async_stop_wemo) static_conf = config.get(CONF_STATIC, []) if static_conf: @@ -116,28 +121,31 @@ async def async_setup_entry(hass, entry): for host, port in static_conf ] ): - if device is None: - continue - - devices.setdefault(device.serialnumber, device) + if device: + wemo_dispatcher.async_add_unique_device(hass, device) if config.get(CONF_DISCOVERY, DEFAULT_DISCOVERY): - _LOGGER.debug("Scanning network for WeMo devices...") - for device in await hass.async_add_executor_job(pywemo.discover_devices): - devices.setdefault( - device.serialnumber, - device, - ) + await wemo_discovery.async_discover_and_schedule() - loaded_components = set() + return True - for device in devices.values(): - _LOGGER.debug( - "Adding WeMo device at %s:%i (%s)", - device.host, - device.port, - device.serialnumber, - ) + +class WemoDispatcher: + """Dispatch WeMo devices to the correct platform.""" + + def __init__(self, config_entry: ConfigEntry): + """Initialize the WemoDispatcher.""" + self._config_entry = config_entry + self._added_serial_numbers = set() + self._loaded_components = set() + + @callback + def async_add_unique_device( + self, hass: HomeAssistant, device: pywemo.WeMoDevice + ) -> None: + """Add a WeMo device to hass if it has not already been added.""" + if device.serialnumber in self._added_serial_numbers: + return component = WEMO_MODEL_DISPATCH.get(device.model_name, SWITCH_DOMAIN) @@ -146,11 +154,13 @@ async def async_setup_entry(hass, entry): # - Component is being loaded, add to backlog # - Component is loaded, backlog is gone, dispatch discovery - if component not in loaded_components: + if component not in self._loaded_components: hass.data[DOMAIN]["pending"][component] = [device] - loaded_components.add(component) + self._loaded_components.add(component) hass.async_create_task( - hass.config_entries.async_forward_entry_setup(entry, component) + hass.config_entries.async_forward_entry_setup( + self._config_entry, component + ) ) elif component in hass.data[DOMAIN]["pending"]: @@ -163,7 +173,48 @@ async def async_setup_entry(hass, entry): device, ) - return True + self._added_serial_numbers.add(device.serialnumber) + + +class WemoDiscovery: + """Use SSDP to discover WeMo devices.""" + + ADDITIONAL_SECONDS_BETWEEN_SCANS = 10 + MAX_SECONDS_BETWEEN_SCANS = 300 + + def __init__(self, hass: HomeAssistant, wemo_dispatcher: WemoDispatcher) -> None: + """Initialize the WemoDiscovery.""" + self._hass = hass + self._wemo_dispatcher = wemo_dispatcher + self._stop = None + self._scan_delay = 0 + + async def async_discover_and_schedule(self, *_) -> None: + """Periodically scan the network looking for WeMo devices.""" + _LOGGER.debug("Scanning network for WeMo devices...") + try: + for device in await self._hass.async_add_executor_job( + pywemo.discover_devices + ): + self._wemo_dispatcher.async_add_unique_device(self._hass, device) + finally: + # Run discovery more frequently after hass has just started. + self._scan_delay = min( + self._scan_delay + self.ADDITIONAL_SECONDS_BETWEEN_SCANS, + self.MAX_SECONDS_BETWEEN_SCANS, + ) + self._stop = async_call_later( + self._hass, + self._scan_delay, + self.async_discover_and_schedule, + ) + + @callback + def async_stop_discovery(self) -> None: + """Stop the periodic background scanning.""" + if self._stop: + self._stop() + self._stop = None def validate_static_config(host, port): diff --git a/tests/components/wemo/conftest.py b/tests/components/wemo/conftest.py index 8eb8b0d9a32..573de21c692 100644 --- a/tests/components/wemo/conftest.py +++ b/tests/components/wemo/conftest.py @@ -1,4 +1,6 @@ """Fixtures for pywemo.""" +import asyncio + import pytest import pywemo @@ -26,9 +28,11 @@ def pywemo_registry_fixture(): registry = create_autospec(pywemo.SubscriptionRegistry, instance=True) registry.callbacks = {} + registry.semaphore = asyncio.Semaphore(value=0) def on_func(device, type_filter, callback): registry.callbacks[device.name] = callback + registry.semaphore.release() registry.on.side_effect = on_func diff --git a/tests/components/wemo/test_init.py b/tests/components/wemo/test_init.py index 87a6ff39552..2af91c0fe32 100644 --- a/tests/components/wemo/test_init.py +++ b/tests/components/wemo/test_init.py @@ -1,9 +1,17 @@ """Tests for the wemo component.""" -from homeassistant.components.wemo import CONF_DISCOVERY, CONF_STATIC +from datetime import timedelta + +import pywemo + +from homeassistant.components.wemo import CONF_DISCOVERY, CONF_STATIC, WemoDiscovery from homeassistant.components.wemo.const import DOMAIN from homeassistant.setup import async_setup_component +from homeassistant.util import dt -from .conftest import MOCK_HOST, MOCK_PORT +from .conftest import MOCK_HOST, MOCK_NAME, MOCK_PORT, MOCK_SERIAL_NUMBER + +from tests.async_mock import create_autospec, patch +from tests.common import async_fire_time_changed async def test_config_no_config(hass): @@ -87,3 +95,47 @@ async def test_static_config_with_invalid_host(hass): }, ) assert not setup_success + + +async def test_discovery(hass, pywemo_registry): + """Verify that discovery dispatches devices to the platform for setup.""" + + def create_device(counter): + """Create a unique mock Motion detector device for each counter value.""" + device = create_autospec(pywemo.Motion, instance=True) + device.host = f"{MOCK_HOST}_{counter}" + device.port = MOCK_PORT + counter + device.name = f"{MOCK_NAME}_{counter}" + device.serialnumber = f"{MOCK_SERIAL_NUMBER}_{counter}" + device.model_name = "Motion" + device.get_state.return_value = 0 # Default to Off + return device + + pywemo_devices = [create_device(0), create_device(1)] + # Setup the component and start discovery. + with patch( + "pywemo.discover_devices", return_value=pywemo_devices + ) as mock_discovery: + assert await async_setup_component( + hass, DOMAIN, {DOMAIN: {CONF_DISCOVERY: True}} + ) + await pywemo_registry.semaphore.acquire() # Returns after platform setup. + mock_discovery.assert_called() + pywemo_devices.append(create_device(2)) + + # Test that discovery runs periodically and the async_dispatcher_send code works. + async_fire_time_changed( + hass, + dt.utcnow() + + timedelta(seconds=WemoDiscovery.ADDITIONAL_SECONDS_BETWEEN_SCANS + 1), + ) + await hass.async_block_till_done() + + # Verify that the expected number of devices were setup. + entity_reg = await hass.helpers.entity_registry.async_get_registry() + entity_entries = list(entity_reg.entities.values()) + assert len(entity_entries) == 3 + + # Verify that hass stops cleanly. + await hass.async_stop() + await hass.async_block_till_done()