SSDP matching improvements (#28285)

* SSDP matching improvements

- support multiple match groups per domain
- require matches on all, not any item in a group
- support matching on all UPnP device description data

* Manifest structure fixes
This commit is contained in:
Ville Skyttä 2019-11-02 21:30:09 +02:00 committed by Paulus Schoutsen
parent a8dff2f2d0
commit 1679ec3245
10 changed files with 113 additions and 92 deletions

View file

@ -6,11 +6,11 @@
"requirements": [
"pydeconz==64"
],
"ssdp": {
"manufacturer": [
"Royal Philips Electronics"
]
},
"ssdp": [
{
"manufacturer": "Royal Philips Electronics"
}
],
"dependencies": [],
"codeowners": [
"@kane610"

View file

@ -6,13 +6,13 @@
"requirements": [
"pyheos==0.6.0"
],
"ssdp": {
"st": [
"urn:schemas-denon-com:device:ACT-Denon:1"
]
},
"ssdp": [
{
"st": "urn:schemas-denon-com:device:ACT-Denon:1"
}
],
"dependencies": [],
"codeowners": [
"@andrewsayre"
]
}
}

View file

@ -6,11 +6,11 @@
"requirements": [
"aiohue==1.9.2"
],
"ssdp": {
"manufacturer": [
"Royal Philips Electronics"
]
},
"ssdp": [
{
"manufacturer": "Royal Philips Electronics"
}
],
"homekit": {
"models": [
"BSB002"

View file

@ -7,10 +7,10 @@
"pysonos==0.0.24"
],
"dependencies": [],
"ssdp": {
"st": [
"urn:schemas-upnp-org:device:ZonePlayer:1"
]
},
"ssdp": [
{
"st": "urn:schemas-upnp-org:device:ZonePlayer:1"
}
],
"codeowners": []
}

View file

@ -104,28 +104,27 @@ class Scanner:
async def _process_entry(self, entry):
"""Process a single entry."""
domains = set(SSDP["st"].get(entry.st, []))
xml_location = entry.location
info = {"st": entry.st}
if not xml_location:
if domains:
return (entry, info_from_entry(entry, None), domains)
return None
if entry.location:
# Multiple entries usually share same location. Make sure
# we fetch it only once.
info_req = self._description_cache.get(xml_location)
# Multiple entries usually share same location. Make sure
# we fetch it only once.
info_req = self._description_cache.get(entry.location)
if info_req is None:
info_req = self._description_cache[
xml_location
] = self.hass.async_create_task(self._fetch_description(xml_location))
if info_req is None:
info_req = self._description_cache[
entry.location
] = self.hass.async_create_task(self._fetch_description(entry.location))
info = await info_req
info.update(await info_req)
domains.update(SSDP["manufacturer"].get(info.get("manufacturer"), []))
domains.update(SSDP["device_type"].get(info.get("deviceType"), []))
domains = set()
for domain, matchers in SSDP.items():
for matcher in matchers:
if all(info.get(k) == v for (k, v) in matcher.items()):
domains.add(domain)
if domains:
return (entry, info_from_entry(entry, info), domains)

View file

@ -6,11 +6,11 @@
"requirements": [
"pywemo==0.4.34"
],
"ssdp": {
"manufacturer": [
"Belkin International Inc."
]
},
"ssdp": [
{
"manufacturer": "Belkin International Inc."
}
],
"homekit": {
"models": [
"Wemo"

View file

@ -6,22 +6,29 @@ To update, run python3 -m script.hassfest
# fmt: off
SSDP = {
"device_type": {},
"manufacturer": {
"Belkin International Inc.": [
"wemo"
],
"Royal Philips Electronics": [
"deconz",
"hue"
]
},
"st": {
"urn:schemas-denon-com:device:ACT-Denon:1": [
"heos"
],
"urn:schemas-upnp-org:device:ZonePlayer:1": [
"sonos"
]
}
"deconz": [
{
"manufacturer": "Royal Philips Electronics"
}
],
"heos": [
{
"st": "urn:schemas-denon-com:device:ACT-Denon:1"
}
],
"hue": [
{
"manufacturer": "Royal Philips Electronics"
}
],
"sonos": [
{
"st": "urn:schemas-upnp-org:device:ZonePlayer:1"
}
],
"wemo": [
{
"manufacturer": "Belkin International Inc."
}
]
}

View file

@ -14,11 +14,7 @@ MANIFEST_SCHEMA = vol.Schema(
vol.Optional("config_flow"): bool,
vol.Optional("zeroconf"): [str],
vol.Optional("ssdp"): vol.Schema(
{
vol.Optional("st"): [str],
vol.Optional("manufacturer"): [str],
vol.Optional("device_type"): [str],
}
vol.All([vol.All(vol.Schema({}, extra=vol.ALLOW_EXTRA), vol.Length(min=1))])
),
vol.Optional("homekit"): vol.Schema({vol.Optional("models"): [str]}),
vol.Required("documentation"): str,

View file

@ -24,11 +24,8 @@ def sort_dict(value):
def generate_and_validate(integrations: Dict[str, Integration]):
"""Validate and generate ssdp data."""
data = {
"st": defaultdict(list),
"manufacturer": defaultdict(list),
"device_type": defaultdict(list),
}
data = defaultdict(list)
for domain in sorted(integrations):
integration = integrations[domain]
@ -56,14 +53,9 @@ def generate_and_validate(integrations: Dict[str, Integration]):
)
continue
for key in "st", "manufacturer", "device_type":
if key not in ssdp:
continue
for matcher in ssdp:
data[domain].append(sort_dict(matcher))
for value in ssdp[key]:
data[key][value].append(domain)
data = sort_dict({key: sort_dict(value) for key, value in data.items()})
return BASE.format(json.dumps(data, indent=4))

View file

@ -17,7 +17,7 @@ async def test_scan_match_st(hass):
with patch(
"netdisco.ssdp.scan", return_value=[Mock(st="mock-st", location=None)]
), patch.dict(gn_ssdp.SSDP["st"], {"mock-st": ["mock-domain"]}), patch.object(
), patch.dict(gn_ssdp.SSDP, {"mock-domain": [{"st": "mock-st"}]}), patch.object(
hass.config_entries.flow, "async_init", return_value=mock_coro()
) as mock_init:
await scanner.async_scan(None)
@ -27,14 +27,15 @@ async def test_scan_match_st(hass):
assert mock_init.mock_calls[0][2]["context"] == {"source": "ssdp"}
async def test_scan_match_manufacturer(hass, aioclient_mock):
"""Test matching based on ST."""
@pytest.mark.parametrize("key", ("manufacturer", "deviceType"))
async def test_scan_match_upnp_devicedesc(hass, aioclient_mock, key):
"""Test matching based on UPnP device description data."""
aioclient_mock.get(
"http://1.1.1.1",
text="""
text=f"""
<root>
<device>
<manufacturer>Paulus</manufacturer>
<{key}>Paulus</{key}>
</device>
</root>
""",
@ -44,9 +45,7 @@ async def test_scan_match_manufacturer(hass, aioclient_mock):
with patch(
"netdisco.ssdp.scan",
return_value=[Mock(st="mock-st", location="http://1.1.1.1")],
), patch.dict(
gn_ssdp.SSDP["manufacturer"], {"Paulus": ["mock-domain"]}
), patch.object(
), patch.dict(gn_ssdp.SSDP, {"mock-domain": [{key: "Paulus"}]}), patch.object(
hass.config_entries.flow, "async_init", return_value=mock_coro()
) as mock_init:
await scanner.async_scan(None)
@ -56,11 +55,11 @@ async def test_scan_match_manufacturer(hass, aioclient_mock):
assert mock_init.mock_calls[0][2]["context"] == {"source": "ssdp"}
async def test_scan_match_device_type(hass, aioclient_mock):
"""Test matching based on ST."""
async def test_scan_not_all_present(hass, aioclient_mock):
"""Test match fails if some specified attributes are not present."""
aioclient_mock.get(
"http://1.1.1.1",
text="""
text=f"""
<root>
<device>
<deviceType>Paulus</deviceType>
@ -74,15 +73,43 @@ async def test_scan_match_device_type(hass, aioclient_mock):
"netdisco.ssdp.scan",
return_value=[Mock(st="mock-st", location="http://1.1.1.1")],
), patch.dict(
gn_ssdp.SSDP["device_type"], {"Paulus": ["mock-domain"]}
gn_ssdp.SSDP,
{"mock-domain": [{"deviceType": "Paulus", "manufacturer": "Paulus"}]},
), patch.object(
hass.config_entries.flow, "async_init", return_value=mock_coro()
) as mock_init:
await scanner.async_scan(None)
assert len(mock_init.mock_calls) == 1
assert mock_init.mock_calls[0][1][0] == "mock-domain"
assert mock_init.mock_calls[0][2]["context"] == {"source": "ssdp"}
assert not mock_init.mock_calls
async def test_scan_not_all_match(hass, aioclient_mock):
"""Test match fails if some specified attribute values differ."""
aioclient_mock.get(
"http://1.1.1.1",
text=f"""
<root>
<device>
<deviceType>Paulus</deviceType>
<manufacturer>Paulus</manufacturer>
</device>
</root>
""",
)
scanner = ssdp.Scanner(hass)
with patch(
"netdisco.ssdp.scan",
return_value=[Mock(st="mock-st", location="http://1.1.1.1")],
), patch.dict(
gn_ssdp.SSDP,
{"mock-domain": [{"deviceType": "Paulus", "manufacturer": "Not-Paulus"}]},
), patch.object(
hass.config_entries.flow, "async_init", return_value=mock_coro()
) as mock_init:
await scanner.async_scan(None)
assert not mock_init.mock_calls
@pytest.mark.parametrize("exc", [asyncio.TimeoutError, aiohttp.ClientError])