diff --git a/homeassistant/components/ssdp/__init__.py b/homeassistant/components/ssdp/__init__.py index f07e88d811a..8cad4a74bf8 100644 --- a/homeassistant/components/ssdp/__init__.py +++ b/homeassistant/components/ssdp/__init__.py @@ -1,14 +1,16 @@ """The SSDP integration.""" import asyncio from datetime import timedelta -import itertools import logging +from typing import Any, Mapping import aiohttp +from async_upnp_client.search import async_search from defusedxml import ElementTree from netdisco import ssdp, util from homeassistant.const import EVENT_HOMEASSISTANT_STARTED +from homeassistant.core import callback from homeassistant.helpers.event import async_track_time_interval from homeassistant.loader import async_get_ssdp @@ -51,12 +53,6 @@ async def async_setup(hass, config): return True -def _run_ssdp_scans(): - _LOGGER.debug("Scanning") - # Run 3 times as packets can get lost - return itertools.chain.from_iterable([ssdp.scan() for _ in range(3)]) - - class Scanner: """Class to manage SSDP scanning.""" @@ -64,25 +60,38 @@ class Scanner: """Initialize class.""" self.hass = hass self.seen = set() + self._entries = [] self._integration_matchers = integration_matchers self._description_cache = {} + async def _on_ssdp_response(self, data: Mapping[str, Any]) -> None: + """Process an ssdp response.""" + self.async_store_entry( + ssdp.UPNPEntry({key.lower(): item for key, item in data.items()}) + ) + + @callback + def async_store_entry(self, entry): + """Save an entry for later processing.""" + self._entries.append(entry) + async def async_scan(self, _): """Scan for new entries.""" - entries = await self.hass.async_add_executor_job(_run_ssdp_scans) - await self._process_entries(entries) + await async_search(async_callback=self._on_ssdp_response) + await self._process_entries() # We clear the cache after each run. We track discovered entries # so will never need a description twice. self._description_cache.clear() + self._entries.clear() - async def _process_entries(self, entries): + async def _process_entries(self): """Process SSDP entries.""" entries_to_process = [] unseen_locations = set() - for entry in entries: + for entry in self._entries: key = (entry.st, entry.location) if key in self.seen: diff --git a/homeassistant/components/ssdp/manifest.json b/homeassistant/components/ssdp/manifest.json index ed20ae9ead6..931119e2398 100644 --- a/homeassistant/components/ssdp/manifest.json +++ b/homeassistant/components/ssdp/manifest.json @@ -2,7 +2,7 @@ "domain": "ssdp", "name": "Simple Service Discovery Protocol (SSDP)", "documentation": "https://www.home-assistant.io/integrations/ssdp", - "requirements": ["defusedxml==0.6.0", "netdisco==2.8.2"], + "requirements": ["defusedxml==0.6.0", "netdisco==2.8.2", "async-upnp-client==0.14.13"], "after_dependencies": ["zeroconf"], "codeowners": [] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index d926471660e..1a1163c7a88 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -3,6 +3,7 @@ PyNaCl==1.3.0 aiohttp==3.7.3 aiohttp_cors==0.7.0 astral==1.10.1 +async-upnp-client==0.14.13 async_timeout==3.0.1 attrs==19.3.0 awesomeversion==21.2.2 diff --git a/requirements_all.txt b/requirements_all.txt index aba43a17ee0..0f96ab51235 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -284,6 +284,7 @@ asmog==0.0.6 asterisk_mbox==0.5.0 # homeassistant.components.dlna_dmr +# homeassistant.components.ssdp # homeassistant.components.upnp async-upnp-client==0.14.13 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index efba3a67c91..148b66e9ac1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -173,6 +173,7 @@ aprslib==0.6.46 arcam-fmj==0.5.3 # homeassistant.components.dlna_dmr +# homeassistant.components.ssdp # homeassistant.components.upnp async-upnp-client==0.14.13 diff --git a/tests/components/ssdp/test_init.py b/tests/components/ssdp/test_init.py index bba809aedbb..8ca82e93bfc 100644 --- a/tests/components/ssdp/test_init.py +++ b/tests/components/ssdp/test_init.py @@ -14,15 +14,18 @@ async def test_scan_match_st(hass, caplog): """Test matching based on ST.""" scanner = ssdp.Scanner(hass, {"mock-domain": [{"st": "mock-st"}]}) - with patch( - "netdisco.ssdp.scan", - return_value=[ + async def _inject_entry(*args, **kwargs): + scanner.async_store_entry( Mock( st="mock-st", location=None, values={"usn": "mock-usn", "server": "mock-server", "ext": ""}, ) - ], + ) + + with patch( + "homeassistant.components.ssdp.async_search", + side_effect=_inject_entry, ), patch.object( hass.config_entries.flow, "async_init", return_value=mock_coro() ) as mock_init: @@ -58,9 +61,14 @@ async def test_scan_match_upnp_devicedesc(hass, aioclient_mock, key): ) scanner = ssdp.Scanner(hass, {"mock-domain": [{key: "Paulus"}]}) + async def _inject_entry(*args, **kwargs): + scanner.async_store_entry( + Mock(st="mock-st", location="http://1.1.1.1", values={}) + ) + with patch( - "netdisco.ssdp.scan", - return_value=[Mock(st="mock-st", location="http://1.1.1.1", values={})], + "homeassistant.components.ssdp.async_search", + side_effect=_inject_entry, ), patch.object( hass.config_entries.flow, "async_init", return_value=mock_coro() ) as mock_init: @@ -95,9 +103,14 @@ async def test_scan_not_all_present(hass, aioclient_mock): }, ) + async def _inject_entry(*args, **kwargs): + scanner.async_store_entry( + Mock(st="mock-st", location="http://1.1.1.1", values={}) + ) + with patch( - "netdisco.ssdp.scan", - return_value=[Mock(st="mock-st", location="http://1.1.1.1", values={})], + "homeassistant.components.ssdp.async_search", + side_effect=_inject_entry, ), patch.object( hass.config_entries.flow, "async_init", return_value=mock_coro() ) as mock_init: @@ -131,9 +144,14 @@ async def test_scan_not_all_match(hass, aioclient_mock): }, ) + async def _inject_entry(*args, **kwargs): + scanner.async_store_entry( + Mock(st="mock-st", location="http://1.1.1.1", values={}) + ) + with patch( - "netdisco.ssdp.scan", - return_value=[Mock(st="mock-st", location="http://1.1.1.1", values={})], + "homeassistant.components.ssdp.async_search", + side_effect=_inject_entry, ), patch.object( hass.config_entries.flow, "async_init", return_value=mock_coro() ) as mock_init: @@ -148,9 +166,14 @@ async def test_scan_description_fetch_fail(hass, aioclient_mock, exc): aioclient_mock.get("http://1.1.1.1", exc=exc) scanner = ssdp.Scanner(hass, {}) + async def _inject_entry(*args, **kwargs): + scanner.async_store_entry( + Mock(st="mock-st", location="http://1.1.1.1", values={}) + ) + with patch( - "netdisco.ssdp.scan", - return_value=[Mock(st="mock-st", location="http://1.1.1.1", values={})], + "homeassistant.components.ssdp.async_search", + side_effect=_inject_entry, ): await scanner.async_scan(None) @@ -165,9 +188,14 @@ async def test_scan_description_parse_fail(hass, aioclient_mock): ) scanner = ssdp.Scanner(hass, {}) + async def _inject_entry(*args, **kwargs): + scanner.async_store_entry( + Mock(st="mock-st", location="http://1.1.1.1", values={}) + ) + with patch( - "netdisco.ssdp.scan", - return_value=[Mock(st="mock-st", location="http://1.1.1.1", values={})], + "homeassistant.components.ssdp.async_search", + side_effect=_inject_entry, ): await scanner.async_scan(None) @@ -196,9 +224,14 @@ async def test_invalid_characters(hass, aioclient_mock): }, ) + async def _inject_entry(*args, **kwargs): + scanner.async_store_entry( + Mock(st="mock-st", location="http://1.1.1.1", values={}) + ) + with patch( - "netdisco.ssdp.scan", - return_value=[Mock(st="mock-st", location="http://1.1.1.1", values={})], + "homeassistant.components.ssdp.async_search", + side_effect=_inject_entry, ), patch.object( hass.config_entries.flow, "async_init", return_value=mock_coro() ) as mock_init: