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
This commit is contained in:
Eric Severance 2020-12-24 03:25:14 -08:00 committed by GitHub
parent 677fc6e2bb
commit d912e91e81
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 137 additions and 30 deletions

View File

@ -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.fan import DOMAIN as FAN_DOMAIN
from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN
from homeassistant.components.switch import DOMAIN as SWITCH_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.const import EVENT_HOMEASSISTANT_STOP
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.event import async_call_later
from .const import DOMAIN from .const import DOMAIN
@ -90,7 +93,7 @@ async def async_setup(hass, config):
return True return True
async def async_setup_entry(hass, entry): async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
"""Set up a wemo config entry.""" """Set up a wemo config entry."""
config = hass.data[DOMAIN].pop("config") config = hass.data[DOMAIN].pop("config")
@ -98,14 +101,16 @@ async def async_setup_entry(hass, entry):
registry = hass.data[DOMAIN]["registry"] = pywemo.SubscriptionRegistry() registry = hass.data[DOMAIN]["registry"] = pywemo.SubscriptionRegistry()
await hass.async_add_executor_job(registry.start) 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.""" """Shutdown Wemo subscriptions and subscription thread on exit."""
_LOGGER.debug("Shutting down WeMo event subscriptions") _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) hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, async_stop_wemo)
devices = {}
static_conf = config.get(CONF_STATIC, []) static_conf = config.get(CONF_STATIC, [])
if static_conf: if static_conf:
@ -116,28 +121,31 @@ async def async_setup_entry(hass, entry):
for host, port in static_conf for host, port in static_conf
] ]
): ):
if device is None: if device:
continue wemo_dispatcher.async_add_unique_device(hass, device)
devices.setdefault(device.serialnumber, device)
if config.get(CONF_DISCOVERY, DEFAULT_DISCOVERY): if config.get(CONF_DISCOVERY, DEFAULT_DISCOVERY):
_LOGGER.debug("Scanning network for WeMo devices...") await wemo_discovery.async_discover_and_schedule()
for device in await hass.async_add_executor_job(pywemo.discover_devices):
devices.setdefault(
device.serialnumber,
device,
)
loaded_components = set() return True
for device in devices.values():
_LOGGER.debug( class WemoDispatcher:
"Adding WeMo device at %s:%i (%s)", """Dispatch WeMo devices to the correct platform."""
device.host,
device.port, def __init__(self, config_entry: ConfigEntry):
device.serialnumber, """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) 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 being loaded, add to backlog
# - Component is loaded, backlog is gone, dispatch discovery # - 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] hass.data[DOMAIN]["pending"][component] = [device]
loaded_components.add(component) self._loaded_components.add(component)
hass.async_create_task( 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"]: elif component in hass.data[DOMAIN]["pending"]:
@ -163,7 +173,48 @@ async def async_setup_entry(hass, entry):
device, 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): def validate_static_config(host, port):

View File

@ -1,4 +1,6 @@
"""Fixtures for pywemo.""" """Fixtures for pywemo."""
import asyncio
import pytest import pytest
import pywemo import pywemo
@ -26,9 +28,11 @@ def pywemo_registry_fixture():
registry = create_autospec(pywemo.SubscriptionRegistry, instance=True) registry = create_autospec(pywemo.SubscriptionRegistry, instance=True)
registry.callbacks = {} registry.callbacks = {}
registry.semaphore = asyncio.Semaphore(value=0)
def on_func(device, type_filter, callback): def on_func(device, type_filter, callback):
registry.callbacks[device.name] = callback registry.callbacks[device.name] = callback
registry.semaphore.release()
registry.on.side_effect = on_func registry.on.side_effect = on_func

View File

@ -1,9 +1,17 @@
"""Tests for the wemo component.""" """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.components.wemo.const import DOMAIN
from homeassistant.setup import async_setup_component 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): async def test_config_no_config(hass):
@ -87,3 +95,47 @@ async def test_static_config_with_invalid_host(hass):
}, },
) )
assert not setup_success 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()