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:
parent
a8dff2f2d0
commit
1679ec3245
10 changed files with 113 additions and 92 deletions
|
@ -6,11 +6,11 @@
|
||||||
"requirements": [
|
"requirements": [
|
||||||
"pydeconz==64"
|
"pydeconz==64"
|
||||||
],
|
],
|
||||||
"ssdp": {
|
"ssdp": [
|
||||||
"manufacturer": [
|
{
|
||||||
"Royal Philips Electronics"
|
"manufacturer": "Royal Philips Electronics"
|
||||||
]
|
}
|
||||||
},
|
],
|
||||||
"dependencies": [],
|
"dependencies": [],
|
||||||
"codeowners": [
|
"codeowners": [
|
||||||
"@kane610"
|
"@kane610"
|
||||||
|
|
|
@ -6,13 +6,13 @@
|
||||||
"requirements": [
|
"requirements": [
|
||||||
"pyheos==0.6.0"
|
"pyheos==0.6.0"
|
||||||
],
|
],
|
||||||
"ssdp": {
|
"ssdp": [
|
||||||
"st": [
|
{
|
||||||
"urn:schemas-denon-com:device:ACT-Denon:1"
|
"st": "urn:schemas-denon-com:device:ACT-Denon:1"
|
||||||
]
|
}
|
||||||
},
|
],
|
||||||
"dependencies": [],
|
"dependencies": [],
|
||||||
"codeowners": [
|
"codeowners": [
|
||||||
"@andrewsayre"
|
"@andrewsayre"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,11 +6,11 @@
|
||||||
"requirements": [
|
"requirements": [
|
||||||
"aiohue==1.9.2"
|
"aiohue==1.9.2"
|
||||||
],
|
],
|
||||||
"ssdp": {
|
"ssdp": [
|
||||||
"manufacturer": [
|
{
|
||||||
"Royal Philips Electronics"
|
"manufacturer": "Royal Philips Electronics"
|
||||||
]
|
}
|
||||||
},
|
],
|
||||||
"homekit": {
|
"homekit": {
|
||||||
"models": [
|
"models": [
|
||||||
"BSB002"
|
"BSB002"
|
||||||
|
|
|
@ -7,10 +7,10 @@
|
||||||
"pysonos==0.0.24"
|
"pysonos==0.0.24"
|
||||||
],
|
],
|
||||||
"dependencies": [],
|
"dependencies": [],
|
||||||
"ssdp": {
|
"ssdp": [
|
||||||
"st": [
|
{
|
||||||
"urn:schemas-upnp-org:device:ZonePlayer:1"
|
"st": "urn:schemas-upnp-org:device:ZonePlayer:1"
|
||||||
]
|
}
|
||||||
},
|
],
|
||||||
"codeowners": []
|
"codeowners": []
|
||||||
}
|
}
|
||||||
|
|
|
@ -104,28 +104,27 @@ class Scanner:
|
||||||
|
|
||||||
async def _process_entry(self, entry):
|
async def _process_entry(self, entry):
|
||||||
"""Process a single 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 entry.location:
|
||||||
if domains:
|
|
||||||
return (entry, info_from_entry(entry, None), domains)
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Multiple entries usually share same location. Make sure
|
# Multiple entries usually share same location. Make sure
|
||||||
# we fetch it only once.
|
# we fetch it only once.
|
||||||
info_req = self._description_cache.get(xml_location)
|
info_req = self._description_cache.get(entry.location)
|
||||||
|
|
||||||
if info_req is None:
|
if info_req is None:
|
||||||
info_req = self._description_cache[
|
info_req = self._description_cache[
|
||||||
xml_location
|
entry.location
|
||||||
] = self.hass.async_create_task(self._fetch_description(xml_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 = set()
|
||||||
domains.update(SSDP["device_type"].get(info.get("deviceType"), []))
|
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:
|
if domains:
|
||||||
return (entry, info_from_entry(entry, info), domains)
|
return (entry, info_from_entry(entry, info), domains)
|
||||||
|
|
|
@ -6,11 +6,11 @@
|
||||||
"requirements": [
|
"requirements": [
|
||||||
"pywemo==0.4.34"
|
"pywemo==0.4.34"
|
||||||
],
|
],
|
||||||
"ssdp": {
|
"ssdp": [
|
||||||
"manufacturer": [
|
{
|
||||||
"Belkin International Inc."
|
"manufacturer": "Belkin International Inc."
|
||||||
]
|
}
|
||||||
},
|
],
|
||||||
"homekit": {
|
"homekit": {
|
||||||
"models": [
|
"models": [
|
||||||
"Wemo"
|
"Wemo"
|
||||||
|
|
|
@ -6,22 +6,29 @@ To update, run python3 -m script.hassfest
|
||||||
# fmt: off
|
# fmt: off
|
||||||
|
|
||||||
SSDP = {
|
SSDP = {
|
||||||
"device_type": {},
|
"deconz": [
|
||||||
"manufacturer": {
|
{
|
||||||
"Belkin International Inc.": [
|
"manufacturer": "Royal Philips Electronics"
|
||||||
"wemo"
|
}
|
||||||
],
|
],
|
||||||
"Royal Philips Electronics": [
|
"heos": [
|
||||||
"deconz",
|
{
|
||||||
"hue"
|
"st": "urn:schemas-denon-com:device:ACT-Denon:1"
|
||||||
]
|
}
|
||||||
},
|
],
|
||||||
"st": {
|
"hue": [
|
||||||
"urn:schemas-denon-com:device:ACT-Denon:1": [
|
{
|
||||||
"heos"
|
"manufacturer": "Royal Philips Electronics"
|
||||||
],
|
}
|
||||||
"urn:schemas-upnp-org:device:ZonePlayer:1": [
|
],
|
||||||
"sonos"
|
"sonos": [
|
||||||
]
|
{
|
||||||
}
|
"st": "urn:schemas-upnp-org:device:ZonePlayer:1"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"wemo": [
|
||||||
|
{
|
||||||
|
"manufacturer": "Belkin International Inc."
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,11 +14,7 @@ MANIFEST_SCHEMA = vol.Schema(
|
||||||
vol.Optional("config_flow"): bool,
|
vol.Optional("config_flow"): bool,
|
||||||
vol.Optional("zeroconf"): [str],
|
vol.Optional("zeroconf"): [str],
|
||||||
vol.Optional("ssdp"): vol.Schema(
|
vol.Optional("ssdp"): vol.Schema(
|
||||||
{
|
vol.All([vol.All(vol.Schema({}, extra=vol.ALLOW_EXTRA), vol.Length(min=1))])
|
||||||
vol.Optional("st"): [str],
|
|
||||||
vol.Optional("manufacturer"): [str],
|
|
||||||
vol.Optional("device_type"): [str],
|
|
||||||
}
|
|
||||||
),
|
),
|
||||||
vol.Optional("homekit"): vol.Schema({vol.Optional("models"): [str]}),
|
vol.Optional("homekit"): vol.Schema({vol.Optional("models"): [str]}),
|
||||||
vol.Required("documentation"): str,
|
vol.Required("documentation"): str,
|
||||||
|
|
|
@ -24,11 +24,8 @@ def sort_dict(value):
|
||||||
|
|
||||||
def generate_and_validate(integrations: Dict[str, Integration]):
|
def generate_and_validate(integrations: Dict[str, Integration]):
|
||||||
"""Validate and generate ssdp data."""
|
"""Validate and generate ssdp data."""
|
||||||
data = {
|
|
||||||
"st": defaultdict(list),
|
data = defaultdict(list)
|
||||||
"manufacturer": defaultdict(list),
|
|
||||||
"device_type": defaultdict(list),
|
|
||||||
}
|
|
||||||
|
|
||||||
for domain in sorted(integrations):
|
for domain in sorted(integrations):
|
||||||
integration = integrations[domain]
|
integration = integrations[domain]
|
||||||
|
@ -56,14 +53,9 @@ def generate_and_validate(integrations: Dict[str, Integration]):
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
for key in "st", "manufacturer", "device_type":
|
for matcher in ssdp:
|
||||||
if key not in ssdp:
|
data[domain].append(sort_dict(matcher))
|
||||||
continue
|
|
||||||
|
|
||||||
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))
|
return BASE.format(json.dumps(data, indent=4))
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -17,7 +17,7 @@ async def test_scan_match_st(hass):
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"netdisco.ssdp.scan", return_value=[Mock(st="mock-st", location=None)]
|
"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()
|
hass.config_entries.flow, "async_init", return_value=mock_coro()
|
||||||
) as mock_init:
|
) as mock_init:
|
||||||
await scanner.async_scan(None)
|
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"}
|
assert mock_init.mock_calls[0][2]["context"] == {"source": "ssdp"}
|
||||||
|
|
||||||
|
|
||||||
async def test_scan_match_manufacturer(hass, aioclient_mock):
|
@pytest.mark.parametrize("key", ("manufacturer", "deviceType"))
|
||||||
"""Test matching based on ST."""
|
async def test_scan_match_upnp_devicedesc(hass, aioclient_mock, key):
|
||||||
|
"""Test matching based on UPnP device description data."""
|
||||||
aioclient_mock.get(
|
aioclient_mock.get(
|
||||||
"http://1.1.1.1",
|
"http://1.1.1.1",
|
||||||
text="""
|
text=f"""
|
||||||
<root>
|
<root>
|
||||||
<device>
|
<device>
|
||||||
<manufacturer>Paulus</manufacturer>
|
<{key}>Paulus</{key}>
|
||||||
</device>
|
</device>
|
||||||
</root>
|
</root>
|
||||||
""",
|
""",
|
||||||
|
@ -44,9 +45,7 @@ async def test_scan_match_manufacturer(hass, aioclient_mock):
|
||||||
with patch(
|
with patch(
|
||||||
"netdisco.ssdp.scan",
|
"netdisco.ssdp.scan",
|
||||||
return_value=[Mock(st="mock-st", location="http://1.1.1.1")],
|
return_value=[Mock(st="mock-st", location="http://1.1.1.1")],
|
||||||
), patch.dict(
|
), patch.dict(gn_ssdp.SSDP, {"mock-domain": [{key: "Paulus"}]}), patch.object(
|
||||||
gn_ssdp.SSDP["manufacturer"], {"Paulus": ["mock-domain"]}
|
|
||||||
), 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:
|
||||||
await scanner.async_scan(None)
|
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"}
|
assert mock_init.mock_calls[0][2]["context"] == {"source": "ssdp"}
|
||||||
|
|
||||||
|
|
||||||
async def test_scan_match_device_type(hass, aioclient_mock):
|
async def test_scan_not_all_present(hass, aioclient_mock):
|
||||||
"""Test matching based on ST."""
|
"""Test match fails if some specified attributes are not present."""
|
||||||
aioclient_mock.get(
|
aioclient_mock.get(
|
||||||
"http://1.1.1.1",
|
"http://1.1.1.1",
|
||||||
text="""
|
text=f"""
|
||||||
<root>
|
<root>
|
||||||
<device>
|
<device>
|
||||||
<deviceType>Paulus</deviceType>
|
<deviceType>Paulus</deviceType>
|
||||||
|
@ -74,15 +73,43 @@ async def test_scan_match_device_type(hass, aioclient_mock):
|
||||||
"netdisco.ssdp.scan",
|
"netdisco.ssdp.scan",
|
||||||
return_value=[Mock(st="mock-st", location="http://1.1.1.1")],
|
return_value=[Mock(st="mock-st", location="http://1.1.1.1")],
|
||||||
), patch.dict(
|
), patch.dict(
|
||||||
gn_ssdp.SSDP["device_type"], {"Paulus": ["mock-domain"]}
|
gn_ssdp.SSDP,
|
||||||
|
{"mock-domain": [{"deviceType": "Paulus", "manufacturer": "Paulus"}]},
|
||||||
), 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:
|
||||||
await scanner.async_scan(None)
|
await scanner.async_scan(None)
|
||||||
|
|
||||||
assert len(mock_init.mock_calls) == 1
|
assert not mock_init.mock_calls
|
||||||
assert mock_init.mock_calls[0][1][0] == "mock-domain"
|
|
||||||
assert mock_init.mock_calls[0][2]["context"] == {"source": "ssdp"}
|
|
||||||
|
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])
|
@pytest.mark.parametrize("exc", [asyncio.TimeoutError, aiohttp.ClientError])
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue