From 12ac4109f42043f4e2ff96eb3bad681e041a4328 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 4 Jun 2021 21:23:51 -1000 Subject: [PATCH] Ensure ssdp can callback messages that do not have an ST (#51436) * Ensure ssdp can callback messages that do not have an ST Sonos sends unsolicited messages when the device reboots. We want to capture these to ensure we can recover the subscriptions as soon as the device reboots * Update homeassistant/components/ssdp/__init__.py Co-authored-by: Martin Hjelmare Co-authored-by: Martin Hjelmare --- homeassistant/components/ssdp/__init__.py | 29 +++++--- tests/components/ssdp/test_init.py | 88 +++++++++++++++++++++++ 2 files changed, 107 insertions(+), 10 deletions(-) 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(