diff --git a/homeassistant/components/deconz/manifest.json b/homeassistant/components/deconz/manifest.json index 63ab17d001a..64902002600 100644 --- a/homeassistant/components/deconz/manifest.json +++ b/homeassistant/components/deconz/manifest.json @@ -6,11 +6,11 @@ "requirements": [ "pydeconz==64" ], - "ssdp": { - "manufacturer": [ - "Royal Philips Electronics" - ] - }, + "ssdp": [ + { + "manufacturer": "Royal Philips Electronics" + } + ], "dependencies": [], "codeowners": [ "@kane610" diff --git a/homeassistant/components/heos/manifest.json b/homeassistant/components/heos/manifest.json index fb21a43356f..684127e519e 100644 --- a/homeassistant/components/heos/manifest.json +++ b/homeassistant/components/heos/manifest.json @@ -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" ] -} \ No newline at end of file +} diff --git a/homeassistant/components/hue/manifest.json b/homeassistant/components/hue/manifest.json index 9a3e478d108..c90b6181559 100644 --- a/homeassistant/components/hue/manifest.json +++ b/homeassistant/components/hue/manifest.json @@ -6,11 +6,11 @@ "requirements": [ "aiohue==1.9.2" ], - "ssdp": { - "manufacturer": [ - "Royal Philips Electronics" - ] - }, + "ssdp": [ + { + "manufacturer": "Royal Philips Electronics" + } + ], "homekit": { "models": [ "BSB002" diff --git a/homeassistant/components/sonos/manifest.json b/homeassistant/components/sonos/manifest.json index 7b0c041b2a9..46723bdcf5f 100644 --- a/homeassistant/components/sonos/manifest.json +++ b/homeassistant/components/sonos/manifest.json @@ -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": [] } diff --git a/homeassistant/components/ssdp/__init__.py b/homeassistant/components/ssdp/__init__.py index d2382748f30..c4d71e0febd 100644 --- a/homeassistant/components/ssdp/__init__.py +++ b/homeassistant/components/ssdp/__init__.py @@ -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) diff --git a/homeassistant/components/wemo/manifest.json b/homeassistant/components/wemo/manifest.json index aa863bcff0d..3b43def230f 100644 --- a/homeassistant/components/wemo/manifest.json +++ b/homeassistant/components/wemo/manifest.json @@ -6,11 +6,11 @@ "requirements": [ "pywemo==0.4.34" ], - "ssdp": { - "manufacturer": [ - "Belkin International Inc." - ] - }, + "ssdp": [ + { + "manufacturer": "Belkin International Inc." + } + ], "homekit": { "models": [ "Wemo" diff --git a/homeassistant/generated/ssdp.py b/homeassistant/generated/ssdp.py index 6d62c47110b..472ad6683ed 100644 --- a/homeassistant/generated/ssdp.py +++ b/homeassistant/generated/ssdp.py @@ -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." + } + ] } diff --git a/script/hassfest/manifest.py b/script/hassfest/manifest.py index 5cf8772686e..16f8b77b5d3 100644 --- a/script/hassfest/manifest.py +++ b/script/hassfest/manifest.py @@ -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, diff --git a/script/hassfest/ssdp.py b/script/hassfest/ssdp.py index 3b02ea18151..d2dd724605e 100644 --- a/script/hassfest/ssdp.py +++ b/script/hassfest/ssdp.py @@ -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)) diff --git a/tests/components/ssdp/test_init.py b/tests/components/ssdp/test_init.py index 81c0f886b41..56b937cf9d9 100644 --- a/tests/components/ssdp/test_init.py +++ b/tests/components/ssdp/test_init.py @@ -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""" - Paulus + <{key}>Paulus """, @@ -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""" Paulus @@ -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""" + + + Paulus + Paulus + + + """, + ) + 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])