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{key}>
""",
@@ -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])