diff --git a/homeassistant/components/wemo/__init__.py b/homeassistant/components/wemo/__init__.py index db380ae11ca..df737f101ba 100644 --- a/homeassistant/components/wemo/__init__.py +++ b/homeassistant/components/wemo/__init__.py @@ -1,5 +1,4 @@ """Support for WeMo device discovery.""" -import asyncio import logging import pywemo @@ -16,9 +15,14 @@ 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 homeassistant.util.async_ import gather_with_concurrency from .const import DOMAIN +# Max number of devices to initialize at once. This limit is in place to +# avoid tying up too many executor threads with WeMo device setup. +MAX_CONCURRENCY = 3 + # Mapping from Wemo model_name to domain. WEMO_MODEL_DISPATCH = { "Bridge": LIGHT_DOMAIN, @@ -114,11 +118,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): static_conf = config.get(CONF_STATIC, []) if static_conf: _LOGGER.debug("Adding statically configured WeMo devices...") - for device in await asyncio.gather( + for device in await gather_with_concurrency( + MAX_CONCURRENCY, *[ hass.async_add_executor_job(validate_static_config, host, port) for host, port in static_conf - ] + ], ): if device: wemo_dispatcher.async_add_unique_device(hass, device) @@ -187,15 +192,44 @@ class WemoDiscovery: self._wemo_dispatcher = wemo_dispatcher self._stop = None self._scan_delay = 0 + self._upnp_entries = set() + + async def async_add_from_upnp_entry(self, entry: pywemo.ssdp.UPNPEntry) -> None: + """Create a WeMoDevice from an UPNPEntry and add it to the dispatcher. + + Uses the self._upnp_entries set to avoid interrogating the same device + multiple times. + """ + if entry in self._upnp_entries: + return + try: + device = await self._hass.async_add_executor_job( + pywemo.discovery.device_from_uuid_and_location, + entry.udn, + entry.location, + ) + except pywemo.PyWeMoException as err: + _LOGGER.error("Unable to setup WeMo %r (%s)", entry, err) + else: + self._wemo_dispatcher.async_add_unique_device(self._hass, device) + self._upnp_entries.add(entry) 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) + # pywemo.ssdp.scan is a light-weight UDP UPnP scan for WeMo devices. + entries = await self._hass.async_add_executor_job(pywemo.ssdp.scan) + + # async_add_from_upnp_entry causes multiple HTTP requests to be sent + # to the WeMo device for the initial setup of the WeMoDevice + # instance. This may take some time to complete. The per-device + # setup work is done in parallel to speed up initial setup for the + # component. + await gather_with_concurrency( + MAX_CONCURRENCY, + *[self.async_add_from_upnp_entry(entry) for entry in entries], + ) finally: # Run discovery more frequently after hass has just started. self._scan_delay = min( diff --git a/tests/components/wemo/test_init.py b/tests/components/wemo/test_init.py index 374222d8688..7c2b43dfd8c 100644 --- a/tests/components/wemo/test_init.py +++ b/tests/components/wemo/test_init.py @@ -100,28 +100,41 @@ async def test_static_config_with_invalid_host(hass): async def test_discovery(hass, pywemo_registry): """Verify that discovery dispatches devices to the platform for setup.""" - def create_device(counter): + def create_device(uuid, location): """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.host = location + device.port = MOCK_PORT + device.name = f"{MOCK_NAME}_{uuid}" + device.serialnumber = f"{MOCK_SERIAL_NUMBER}_{uuid}" device.model_name = "Motion" device.get_state.return_value = 0 # Default to Off return device - pywemo_devices = [create_device(0), create_device(1)] + def create_upnp_entry(counter): + return pywemo.ssdp.UPNPEntry.from_response( + "\r\n".join( + [ + "", + f"LOCATION: http://192.168.1.100:{counter}/setup.xml", + f"USN: uuid:Socket-1_0-SERIAL{counter}::upnp:rootdevice", + "", + ] + ) + ) + + upnp_entries = [create_upnp_entry(0), create_upnp_entry(1)] # Setup the component and start discovery. with patch( - "pywemo.discover_devices", return_value=pywemo_devices - ) as mock_discovery: + "pywemo.discovery.device_from_uuid_and_location", side_effect=create_device + ), patch("pywemo.ssdp.scan", return_value=upnp_entries) as mock_scan: 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)) + mock_scan.assert_called() + # Add two of the same entries to test deduplication. + upnp_entries.extend([create_upnp_entry(2), create_upnp_entry(2)]) # Test that discovery runs periodically and the async_dispatcher_send code works. async_fire_time_changed(