diff --git a/homeassistant/components/ssdp/__init__.py b/homeassistant/components/ssdp/__init__.py index 3cd2cb87f70..d03f8967311 100644 --- a/homeassistant/components/ssdp/__init__.py +++ b/homeassistant/components/ssdp/__init__.py @@ -153,7 +153,7 @@ class Scanner: ) -> None: """Initialize class.""" self.hass = hass - self.seen: set[tuple[str, str]] = set() + self.seen: set[tuple[str, str | None]] = set() self.cache: dict[tuple[str, str], Mapping[str, str]] = {} self._integration_matchers = integration_matchers self._cancel_scan: Callable[[], None] | None = None @@ -268,20 +268,28 @@ class Scanner: domains.add(domain) return domains + def _async_seen(self, header_st: str | None, header_location: str | None) -> bool: + """Check if we have seen a specific st and optional location.""" + if header_st is None: + return True + return (header_st, header_location) in self.seen + + def _async_see(self, header_st: str | None, header_location: str | None) -> None: + """Mark a specific st and optional location as seen.""" + if header_st is not None: + self.seen.add((header_st, header_location)) + async def _async_process_entry(self, headers: Mapping[str, str]) -> None: """Process SSDP entries.""" _LOGGER.debug("_async_process_entry: %s", headers) - if "st" not in headers or "location" not in headers: - return - h_st = headers["st"] - h_location = headers["location"] - key = (h_st, h_location) + h_st = headers.get("st") + h_location = headers.get("location") - if udn := _udn_from_usn(headers.get("usn")): + if h_st and (udn := _udn_from_usn(headers.get("usn"))): self.cache[(udn, h_st)] = headers callbacks = self._async_get_matching_callbacks(headers) - if key in self.seen and not callbacks: + if self._async_seen(h_st, h_location) and not callbacks: return assert self.description_manager is not None @@ -290,9 +298,10 @@ class Scanner: discovery_info = discovery_info_from_headers_and_request(info_with_req) _async_process_callbacks(callbacks, discovery_info) - if key in self.seen: + + if self._async_seen(h_st, h_location): return - self.seen.add(key) + self._async_see(h_st, h_location) for domain in self._async_matching_domains(info_with_req): _LOGGER.debug("Discovered %s at %s", domain, h_location) diff --git a/tests/components/ssdp/test_init.py b/tests/components/ssdp/test_init.py index 6b41544a384..2b527064ff8 100644 --- a/tests/components/ssdp/test_init.py +++ b/tests/components/ssdp/test_init.py @@ -478,6 +478,94 @@ async def test_scan_with_registered_callback(hass, aioclient_mock, caplog): assert "Failed to callback info" in caplog.text +async def test_unsolicited_ssdp_registered_callback(hass, aioclient_mock, caplog): + """Test matching based on callback can handle unsolicited ssdp traffic without st.""" + aioclient_mock.get( + "http://10.6.9.12:1400/xml/device_description.xml", + text=""" + + + Paulus + + + """, + ) + mock_ssdp_response = { + "location": "http://10.6.9.12:1400/xml/device_description.xml", + "nt": "uuid:RINCON_1111BB963FD801400", + "nts": "ssdp:alive", + "server": "Linux UPnP/1.0 Sonos/63.2-88230 (ZPS12)", + "usn": "uuid:RINCON_1111BB963FD801400", + "x-rincon-household": "Sonos_dfjfkdghjhkjfhkdjfhkd", + "x-rincon-bootseq": "250", + "bootid.upnp.org": "250", + "x-rincon-wifimode": "0", + "x-rincon-variant": "1", + "household.smartspeaker.audio": "Sonos_v3294823948542543534", + } + intergration_callbacks = [] + + @callback + def _async_intergration_callbacks(info): + intergration_callbacks.append(info) + + def _generate_fake_ssdp_listener(*args, **kwargs): + listener = SSDPListener(*args, **kwargs) + + async def _async_callback(*_): + await listener.async_callback(mock_ssdp_response) + + @callback + def _callback(*_): + hass.async_create_task(listener.async_callback(mock_ssdp_response)) + + listener.async_start = _async_callback + listener.async_search = _callback + return listener + + with patch( + "homeassistant.components.ssdp.SSDPListener", + new=_generate_fake_ssdp_listener, + ): + hass.state = CoreState.stopped + assert await async_setup_component(hass, ssdp.DOMAIN, {ssdp.DOMAIN: {}}) + await hass.async_block_till_done() + ssdp.async_register_callback( + hass, + _async_intergration_callbacks, + {"nts": "ssdp:alive", "x-rincon-bootseq": MATCH_ALL}, + ) + await hass.async_block_till_done() + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=200)) + await hass.async_block_till_done() + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + hass.state = CoreState.running + await hass.async_block_till_done() + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=200)) + await hass.async_block_till_done() + assert hass.state == CoreState.running + + assert ( + len(intergration_callbacks) == 2 + ) # unsolicited callbacks without st are not cached + assert intergration_callbacks[0] == { + "UDN": "uuid:RINCON_1111BB963FD801400", + "bootid.upnp.org": "250", + "deviceType": "Paulus", + "household.smartspeaker.audio": "Sonos_v3294823948542543534", + "nt": "uuid:RINCON_1111BB963FD801400", + "nts": "ssdp:alive", + "ssdp_location": "http://10.6.9.12:1400/xml/device_description.xml", + "ssdp_server": "Linux UPnP/1.0 Sonos/63.2-88230 (ZPS12)", + "ssdp_usn": "uuid:RINCON_1111BB963FD801400", + "x-rincon-bootseq": "250", + "x-rincon-household": "Sonos_dfjfkdghjhkjfhkdjfhkd", + "x-rincon-variant": "1", + "x-rincon-wifimode": "0", + } + assert "Failed to callback info" not in caplog.text + + async def test_scan_second_hit(hass, aioclient_mock, caplog): """Test matching on second scan.""" aioclient_mock.get(