Switch ssdp to be async by using async_upnp_client for scanning (#46554)

SSDP scans no longer runs in the executor

This is an interim step that converts the async_upnp_client
response to netdisco's object to ensure fully backwards
compatibility
This commit is contained in:
J. Nick Koston 2021-02-18 00:00:11 -10:00 committed by GitHub
parent e9334347eb
commit 39785c5cef
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 73 additions and 28 deletions

View File

@ -1,14 +1,16 @@
"""The SSDP integration.""" """The SSDP integration."""
import asyncio import asyncio
from datetime import timedelta from datetime import timedelta
import itertools
import logging import logging
from typing import Any, Mapping
import aiohttp import aiohttp
from async_upnp_client.search import async_search
from defusedxml import ElementTree from defusedxml import ElementTree
from netdisco import ssdp, util from netdisco import ssdp, util
from homeassistant.const import EVENT_HOMEASSISTANT_STARTED from homeassistant.const import EVENT_HOMEASSISTANT_STARTED
from homeassistant.core import callback
from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.event import async_track_time_interval
from homeassistant.loader import async_get_ssdp from homeassistant.loader import async_get_ssdp
@ -51,12 +53,6 @@ async def async_setup(hass, config):
return True 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 Scanner:
"""Class to manage SSDP scanning.""" """Class to manage SSDP scanning."""
@ -64,25 +60,38 @@ class Scanner:
"""Initialize class.""" """Initialize class."""
self.hass = hass self.hass = hass
self.seen = set() self.seen = set()
self._entries = []
self._integration_matchers = integration_matchers self._integration_matchers = integration_matchers
self._description_cache = {} 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, _): async def async_scan(self, _):
"""Scan for new entries.""" """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 # We clear the cache after each run. We track discovered entries
# so will never need a description twice. # so will never need a description twice.
self._description_cache.clear() self._description_cache.clear()
self._entries.clear()
async def _process_entries(self, entries): async def _process_entries(self):
"""Process SSDP entries.""" """Process SSDP entries."""
entries_to_process = [] entries_to_process = []
unseen_locations = set() unseen_locations = set()
for entry in entries: for entry in self._entries:
key = (entry.st, entry.location) key = (entry.st, entry.location)
if key in self.seen: if key in self.seen:

View File

@ -2,7 +2,7 @@
"domain": "ssdp", "domain": "ssdp",
"name": "Simple Service Discovery Protocol (SSDP)", "name": "Simple Service Discovery Protocol (SSDP)",
"documentation": "https://www.home-assistant.io/integrations/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"], "after_dependencies": ["zeroconf"],
"codeowners": [] "codeowners": []
} }

View File

@ -3,6 +3,7 @@ PyNaCl==1.3.0
aiohttp==3.7.3 aiohttp==3.7.3
aiohttp_cors==0.7.0 aiohttp_cors==0.7.0
astral==1.10.1 astral==1.10.1
async-upnp-client==0.14.13
async_timeout==3.0.1 async_timeout==3.0.1
attrs==19.3.0 attrs==19.3.0
awesomeversion==21.2.2 awesomeversion==21.2.2

View File

@ -284,6 +284,7 @@ asmog==0.0.6
asterisk_mbox==0.5.0 asterisk_mbox==0.5.0
# homeassistant.components.dlna_dmr # homeassistant.components.dlna_dmr
# homeassistant.components.ssdp
# homeassistant.components.upnp # homeassistant.components.upnp
async-upnp-client==0.14.13 async-upnp-client==0.14.13

View File

@ -173,6 +173,7 @@ aprslib==0.6.46
arcam-fmj==0.5.3 arcam-fmj==0.5.3
# homeassistant.components.dlna_dmr # homeassistant.components.dlna_dmr
# homeassistant.components.ssdp
# homeassistant.components.upnp # homeassistant.components.upnp
async-upnp-client==0.14.13 async-upnp-client==0.14.13

View File

@ -14,15 +14,18 @@ async def test_scan_match_st(hass, caplog):
"""Test matching based on ST.""" """Test matching based on ST."""
scanner = ssdp.Scanner(hass, {"mock-domain": [{"st": "mock-st"}]}) scanner = ssdp.Scanner(hass, {"mock-domain": [{"st": "mock-st"}]})
with patch( async def _inject_entry(*args, **kwargs):
"netdisco.ssdp.scan", scanner.async_store_entry(
return_value=[
Mock( Mock(
st="mock-st", st="mock-st",
location=None, location=None,
values={"usn": "mock-usn", "server": "mock-server", "ext": ""}, values={"usn": "mock-usn", "server": "mock-server", "ext": ""},
) )
], )
with patch(
"homeassistant.components.ssdp.async_search",
side_effect=_inject_entry,
), patch.object( ), patch.object(
hass.config_entries.flow, "async_init", return_value=mock_coro() hass.config_entries.flow, "async_init", return_value=mock_coro()
) as mock_init: ) 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"}]}) 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( with patch(
"netdisco.ssdp.scan", "homeassistant.components.ssdp.async_search",
return_value=[Mock(st="mock-st", location="http://1.1.1.1", values={})], side_effect=_inject_entry,
), patch.object( ), patch.object(
hass.config_entries.flow, "async_init", return_value=mock_coro() hass.config_entries.flow, "async_init", return_value=mock_coro()
) as mock_init: ) 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( with patch(
"netdisco.ssdp.scan", "homeassistant.components.ssdp.async_search",
return_value=[Mock(st="mock-st", location="http://1.1.1.1", values={})], side_effect=_inject_entry,
), patch.object( ), patch.object(
hass.config_entries.flow, "async_init", return_value=mock_coro() hass.config_entries.flow, "async_init", return_value=mock_coro()
) as mock_init: ) 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( with patch(
"netdisco.ssdp.scan", "homeassistant.components.ssdp.async_search",
return_value=[Mock(st="mock-st", location="http://1.1.1.1", values={})], side_effect=_inject_entry,
), patch.object( ), patch.object(
hass.config_entries.flow, "async_init", return_value=mock_coro() hass.config_entries.flow, "async_init", return_value=mock_coro()
) as mock_init: ) 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) aioclient_mock.get("http://1.1.1.1", exc=exc)
scanner = ssdp.Scanner(hass, {}) 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( with patch(
"netdisco.ssdp.scan", "homeassistant.components.ssdp.async_search",
return_value=[Mock(st="mock-st", location="http://1.1.1.1", values={})], side_effect=_inject_entry,
): ):
await scanner.async_scan(None) await scanner.async_scan(None)
@ -165,9 +188,14 @@ async def test_scan_description_parse_fail(hass, aioclient_mock):
) )
scanner = ssdp.Scanner(hass, {}) 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( with patch(
"netdisco.ssdp.scan", "homeassistant.components.ssdp.async_search",
return_value=[Mock(st="mock-st", location="http://1.1.1.1", values={})], side_effect=_inject_entry,
): ):
await scanner.async_scan(None) 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( with patch(
"netdisco.ssdp.scan", "homeassistant.components.ssdp.async_search",
return_value=[Mock(st="mock-st", location="http://1.1.1.1", values={})], side_effect=_inject_entry,
), patch.object( ), patch.object(
hass.config_entries.flow, "async_init", return_value=mock_coro() hass.config_entries.flow, "async_init", return_value=mock_coro()
) as mock_init: ) as mock_init: