diff --git a/homeassistant/components/ssdp/__init__.py b/homeassistant/components/ssdp/__init__.py index f5e2a012730..ccd69961975 100644 --- a/homeassistant/components/ssdp/__init__.py +++ b/homeassistant/components/ssdp/__init__.py @@ -12,7 +12,7 @@ from ipaddress import IPv4Address, IPv6Address import logging import socket from time import time -from typing import Any +from typing import TYPE_CHECKING, Any from urllib.parse import urljoin import xml.etree.ElementTree as ET @@ -47,6 +47,7 @@ from homeassistant.core import Event, HassJob, HomeAssistant, callback as core_c from homeassistant.data_entry_flow import BaseServiceInfo from homeassistant.helpers import config_validation as cv, discovery_flow from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.instance_id import async_get as async_get_instance_id from homeassistant.helpers.network import NoURLAvailableError, get_url @@ -394,6 +395,12 @@ class Scanner: self.hass, self.async_scan, SCAN_INTERVAL, name="SSDP scanner" ) + async_dispatcher_connect( + self.hass, + config_entries.signal_discovered_config_entry_removed(DOMAIN), + self._handle_config_entry_removed, + ) + # Trigger the initial-scan. await self.async_scan() @@ -502,6 +509,7 @@ class Scanner: dst: DeviceOrServiceType, source: SsdpSource, info_desc: Mapping[str, Any], + skip_callbacks: bool = False, ) -> None: """Handle a device/service change.""" matching_domains: set[str] = set() @@ -526,7 +534,7 @@ class Scanner: ) discovery_info.x_homeassistant_matching_domains = matching_domains - if callbacks: + if callbacks and not skip_callbacks: ssdp_change = SSDP_SOURCE_SSDP_CHANGE_MAPPING[source] _async_process_callbacks(self.hass, callbacks, discovery_info, ssdp_change) @@ -537,14 +545,20 @@ class Scanner: _LOGGER.debug("Discovery info: %s", discovery_info) - location = ssdp_device.location + if not matching_domains: + return # avoid creating DiscoveryKey if there are no matches + + discovery_key = discovery_flow.DiscoveryKey( + domain=DOMAIN, key=ssdp_device.udn, version=1 + ) for domain in matching_domains: - _LOGGER.debug("Discovered %s at %s", domain, location) + _LOGGER.debug("Discovered %s at %s", domain, ssdp_device.location) discovery_flow.async_create_flow( self.hass, domain, {"source": config_entries.SOURCE_SSDP}, discovery_info, + discovery_key=discovery_key, ) def _async_dismiss_discoveries( @@ -565,14 +579,13 @@ class Scanner: ) -> Mapping[str, str]: """Get description dict.""" assert self._description_cache is not None + cache = self._description_cache - has_description, description = self._description_cache.peek_description_dict( - location - ) + has_description, description = cache.peek_description_dict(location) if has_description: return description or {} - return await self._description_cache.async_get_description_dict(location) or {} + return await cache.async_get_description_dict(location) or {} async def _async_headers_to_discovery_info( self, ssdp_device: SsdpDevice, headers: CaseInsensitiveDict @@ -581,8 +594,6 @@ class Scanner: Building this is a bit expensive so we only do it on demand. """ - assert self._description_cache is not None - location = headers["location"] info_desc = await self._async_get_description_dict(location) return discovery_info_from_headers_and_description( @@ -618,6 +629,37 @@ class Scanner: if ssdp_device.udn == udn ] + @core_callback + def _handle_config_entry_removed( + self, + entry: config_entries.ConfigEntry, + ) -> None: + """Handle config entry changes.""" + if TYPE_CHECKING: + assert self._description_cache is not None + cache = self._description_cache + for discovery_key in entry.discovery_keys[DOMAIN]: + if discovery_key.version != 1 or not isinstance(discovery_key.key, str): + continue + udn = discovery_key.key + _LOGGER.debug("Rediscover service %s", udn) + + for ssdp_device in self._ssdp_devices: + if ssdp_device.udn != udn: + continue + for dst in ssdp_device.all_combined_headers: + has_cached_desc, info_desc = cache.peek_description_dict( + ssdp_device.location + ) + if has_cached_desc and info_desc: + self._ssdp_listener_process_callback( + ssdp_device, + dst, + SsdpSource.SEARCH, + info_desc, + True, # Skip integration callbacks + ) + def discovery_info_from_headers_and_description( ssdp_device: SsdpDevice, diff --git a/tests/components/ssdp/test_init.py b/tests/components/ssdp/test_init.py index d10496500d2..5592f7a6809 100644 --- a/tests/components/ssdp/test_init.py +++ b/tests/components/ssdp/test_init.py @@ -18,10 +18,16 @@ from homeassistant.const import ( MATCH_ALL, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers.discovery_flow import DiscoveryKey from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util -from tests.common import async_fire_time_changed +from tests.common import ( + MockConfigEntry, + MockModule, + async_fire_time_changed, + mock_integration, +) from tests.test_util.aiohttp import AiohttpClientMocker @@ -65,7 +71,8 @@ async def test_ssdp_flow_dispatched_on_st( assert len(mock_flow_init.mock_calls) == 1 assert mock_flow_init.mock_calls[0][1][0] == "mock-domain" assert mock_flow_init.mock_calls[0][2]["context"] == { - "source": config_entries.SOURCE_SSDP + "discovery_key": DiscoveryKey(domain="ssdp", key="uuid:mock-udn", version=1), + "source": config_entries.SOURCE_SSDP, } mock_call_data: ssdp.SsdpServiceInfo = mock_flow_init.mock_calls[0][2]["data"] assert mock_call_data.ssdp_st == "mock-st" @@ -108,7 +115,8 @@ async def test_ssdp_flow_dispatched_on_manufacturer_url( assert len(mock_flow_init.mock_calls) == 1 assert mock_flow_init.mock_calls[0][1][0] == "mock-domain" assert mock_flow_init.mock_calls[0][2]["context"] == { - "source": config_entries.SOURCE_SSDP + "discovery_key": DiscoveryKey(domain="ssdp", key="uuid:mock-udn", version=1), + "source": config_entries.SOURCE_SSDP, } mock_call_data: ssdp.SsdpServiceInfo = mock_flow_init.mock_calls[0][2]["data"] assert mock_call_data.ssdp_st == "mock-st" @@ -163,7 +171,8 @@ async def test_scan_match_upnp_devicedesc_manufacturer( assert len(mock_flow_init.mock_calls) == 1 assert mock_flow_init.mock_calls[0][1][0] == "mock-domain" assert mock_flow_init.mock_calls[0][2]["context"] == { - "source": config_entries.SOURCE_SSDP + "discovery_key": DiscoveryKey(domain="ssdp", key="uuid:mock-udn", version=1), + "source": config_entries.SOURCE_SSDP, } @@ -208,7 +217,8 @@ async def test_scan_match_upnp_devicedesc_devicetype( assert len(mock_flow_init.mock_calls) == 1 assert mock_flow_init.mock_calls[0][1][0] == "mock-domain" assert mock_flow_init.mock_calls[0][2]["context"] == { - "source": config_entries.SOURCE_SSDP + "discovery_key": DiscoveryKey(domain="ssdp", key="uuid:mock-udn", version=1), + "source": config_entries.SOURCE_SSDP, } @@ -339,7 +349,14 @@ async def test_flow_start_only_alive( await hass.async_block_till_done(wait_background_tasks=True) mock_flow_init.assert_awaited_once_with( - "mock-domain", context={"source": config_entries.SOURCE_SSDP}, data=ANY + "mock-domain", + context={ + "discovery_key": DiscoveryKey( + domain="ssdp", key="uuid:mock-udn", version=1 + ), + "source": config_entries.SOURCE_SSDP, + }, + data=ANY, ) # ssdp:alive advertisement should start a flow @@ -356,7 +373,14 @@ async def test_flow_start_only_alive( ssdp_listener._on_alive(mock_ssdp_advertisement) await hass.async_block_till_done() mock_flow_init.assert_awaited_once_with( - "mock-domain", context={"source": config_entries.SOURCE_SSDP}, data=ANY + "mock-domain", + context={ + "discovery_key": DiscoveryKey( + domain="ssdp", key="uuid:mock-udn", version=1 + ), + "source": config_entries.SOURCE_SSDP, + }, + data=ANY, ) # ssdp:byebye advertisement should not start a flow @@ -372,7 +396,14 @@ async def test_flow_start_only_alive( ssdp_listener._on_update(mock_ssdp_advertisement) await hass.async_block_till_done() mock_flow_init.assert_awaited_once_with( - "mock-domain", context={"source": config_entries.SOURCE_SSDP}, data=ANY + "mock-domain", + context={ + "discovery_key": DiscoveryKey( + domain="ssdp", key="uuid:mock-udn", version=1 + ), + "source": config_entries.SOURCE_SSDP, + }, + data=ANY, ) @@ -824,7 +855,14 @@ async def test_flow_dismiss_on_byebye( await hass.async_block_till_done(wait_background_tasks=True) mock_flow_init.assert_awaited_once_with( - "mock-domain", context={"source": config_entries.SOURCE_SSDP}, data=ANY + "mock-domain", + context={ + "discovery_key": DiscoveryKey( + domain="ssdp", key="uuid:mock-udn", version=1 + ), + "source": config_entries.SOURCE_SSDP, + }, + data=ANY, ) # ssdp:alive advertisement should start a flow @@ -841,7 +879,14 @@ async def test_flow_dismiss_on_byebye( ssdp_listener._on_alive(mock_ssdp_advertisement) await hass.async_block_till_done(wait_background_tasks=True) mock_flow_init.assert_awaited_once_with( - "mock-domain", context={"source": config_entries.SOURCE_SSDP}, data=ANY + "mock-domain", + context={ + "discovery_key": DiscoveryKey( + domain="ssdp", key="uuid:mock-udn", version=1 + ), + "source": config_entries.SOURCE_SSDP, + }, + data=ANY, ) mock_ssdp_advertisement["nts"] = "ssdp:byebye" @@ -859,3 +904,298 @@ async def test_flow_dismiss_on_byebye( assert len(mock_async_progress_by_init_data_type.mock_calls) == 1 assert mock_async_abort.mock_calls[0][1][0] == "mock_flow_id" + + +@patch( + "homeassistant.components.ssdp.async_get_ssdp", + return_value={"mock-domain": [{"st": "mock-st"}]}, +) +@pytest.mark.parametrize( + ( + "entry_domain", + "entry_discovery_keys", + ), + [ + # Matching discovery key + ( + "mock-domain", + {"ssdp": (DiscoveryKey(domain="ssdp", key="uuid:mock-udn", version=1),)}, + ), + # Matching discovery key + ( + "mock-domain", + { + "ssdp": (DiscoveryKey(domain="ssdp", key="uuid:mock-udn", version=1),), + "other": (DiscoveryKey(domain="other", key="blah", version=1),), + }, + ), + # Matching discovery key, other domain + # Note: Rediscovery is not currently restricted to the domain of the removed + # entry. Such a check can be added if needed. + ( + "comp", + {"ssdp": (DiscoveryKey(domain="ssdp", key="uuid:mock-udn", version=1),)}, + ), + ], +) +@pytest.mark.parametrize("entry_source", [config_entries.SOURCE_IGNORE]) +async def test_ssdp_rediscover( + mock_get_ssdp, + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + mock_flow_init, + entry_domain: str, + entry_discovery_keys: tuple, + entry_source: str, +) -> None: + """Test we reinitiate flows when an ignored config entry is removed.""" + entry = MockConfigEntry( + domain=entry_domain, + discovery_keys=entry_discovery_keys, + unique_id="mock-unique-id", + state=config_entries.ConfigEntryState.LOADED, + source=entry_source, + ) + entry.add_to_hass(hass) + + mock_ssdp_search_response = _ssdp_headers( + { + "st": "mock-st", + "location": "http://1.1.1.1", + "usn": "uuid:mock-udn::mock-st", + "server": "mock-server", + "ext": "", + "_source": "search", + } + ) + aioclient_mock.get( + "http://1.1.1.1", + text=""" + + + Paulus + Paulus + + + """, + ) + ssdp_listener = await init_ssdp_component(hass) + ssdp_listener._on_search(mock_ssdp_search_response) + await hass.async_block_till_done() + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + expected_context = { + "discovery_key": DiscoveryKey(domain="ssdp", key="uuid:mock-udn", version=1), + "source": config_entries.SOURCE_SSDP, + } + assert len(mock_flow_init.mock_calls) == 1 + assert mock_flow_init.mock_calls[0][1][0] == "mock-domain" + assert mock_flow_init.mock_calls[0][2]["context"] == expected_context + mock_call_data: ssdp.SsdpServiceInfo = mock_flow_init.mock_calls[0][2]["data"] + assert mock_call_data.ssdp_st == "mock-st" + assert mock_call_data.ssdp_location == "http://1.1.1.1" + + await hass.config_entries.async_remove(entry.entry_id) + await hass.async_block_till_done() + + assert len(mock_flow_init.mock_calls) == 3 + assert mock_flow_init.mock_calls[1][1][0] == entry_domain + assert mock_flow_init.mock_calls[1][2]["context"] == {"source": "unignore"} + assert mock_flow_init.mock_calls[2][1][0] == "mock-domain" + assert mock_flow_init.mock_calls[2][2]["context"] == expected_context + assert ( + mock_flow_init.mock_calls[2][2]["data"] + == mock_flow_init.mock_calls[0][2]["data"] + ) + + +@patch( + "homeassistant.components.ssdp.async_get_ssdp", + return_value={"mock-domain": [{"st": "mock-st"}]}, +) +@pytest.mark.parametrize( + ( + "entry_domain", + "entry_discovery_keys", + ), + [ + # Matching discovery key + ( + "mock-domain", + {"ssdp": (DiscoveryKey(domain="ssdp", key="uuid:mock-udn", version=1),)}, + ), + # Matching discovery key + ( + "mock-domain", + { + "ssdp": (DiscoveryKey(domain="ssdp", key="uuid:mock-udn", version=1),), + "other": (DiscoveryKey(domain="other", key="blah", version=1),), + }, + ), + # Matching discovery key, other domain + # Note: Rediscovery is not currently restricted to the domain of the removed + # entry. Such a check can be added if needed. + ( + "comp", + {"ssdp": (DiscoveryKey(domain="ssdp", key="uuid:mock-udn", version=1),)}, + ), + ], +) +@pytest.mark.parametrize( + "entry_source", [config_entries.SOURCE_USER, config_entries.SOURCE_ZEROCONF] +) +async def test_ssdp_rediscover_2( + mock_get_ssdp, + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + mock_flow_init, + entry_domain: str, + entry_discovery_keys: tuple, + entry_source: str, +) -> None: + """Test we reinitiate flows when an ignored config entry is removed. + + This test can be merged with test_zeroconf_rediscover when + async_step_unignore has been removed from the ConfigFlow base class. + """ + entry = MockConfigEntry( + domain=entry_domain, + discovery_keys=entry_discovery_keys, + unique_id="mock-unique-id", + state=config_entries.ConfigEntryState.LOADED, + source=entry_source, + ) + entry.add_to_hass(hass) + + mock_ssdp_search_response = _ssdp_headers( + { + "st": "mock-st", + "location": "http://1.1.1.1", + "usn": "uuid:mock-udn::mock-st", + "server": "mock-server", + "ext": "", + "_source": "search", + } + ) + aioclient_mock.get( + "http://1.1.1.1", + text=""" + + + Paulus + Paulus + + + """, + ) + ssdp_listener = await init_ssdp_component(hass) + ssdp_listener._on_search(mock_ssdp_search_response) + await hass.async_block_till_done() + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + expected_context = { + "discovery_key": DiscoveryKey(domain="ssdp", key="uuid:mock-udn", version=1), + "source": config_entries.SOURCE_SSDP, + } + assert len(mock_flow_init.mock_calls) == 1 + assert mock_flow_init.mock_calls[0][1][0] == "mock-domain" + assert mock_flow_init.mock_calls[0][2]["context"] == expected_context + mock_call_data: ssdp.SsdpServiceInfo = mock_flow_init.mock_calls[0][2]["data"] + assert mock_call_data.ssdp_st == "mock-st" + assert mock_call_data.ssdp_location == "http://1.1.1.1" + + await hass.config_entries.async_remove(entry.entry_id) + await hass.async_block_till_done() + + assert len(mock_flow_init.mock_calls) == 2 + assert mock_flow_init.mock_calls[1][1][0] == "mock-domain" + assert mock_flow_init.mock_calls[1][2]["context"] == expected_context + assert ( + mock_flow_init.mock_calls[1][2]["data"] + == mock_flow_init.mock_calls[0][2]["data"] + ) + + +@patch( + "homeassistant.components.ssdp.async_get_ssdp", + return_value={"mock-domain": [{"st": "mock-st"}]}, +) +@pytest.mark.parametrize( + ( + "entry_domain", + "entry_discovery_keys", + "entry_source", + "entry_unique_id", + ), + [ + # Discovery key from other domain + ( + "mock-domain", + {"dhcp": (DiscoveryKey(domain="dhcp", key="uuid:mock-udn", version=1),)}, + config_entries.SOURCE_IGNORE, + "mock-unique-id", + ), + # Discovery key from the future + ( + "mock-domain", + {"ssdp": (DiscoveryKey(domain="ssdp", key="uuid:mock-udn", version=2),)}, + config_entries.SOURCE_IGNORE, + "mock-unique-id", + ), + ], +) +async def test_ssdp_rediscover_no_match( + mock_get_ssdp, + hass: HomeAssistant, + mock_flow_init, + entry_domain: str, + entry_discovery_keys: tuple, + entry_source: str, + entry_unique_id: str, +) -> None: + """Test we don't reinitiate flows when a non matching config entry is removed.""" + mock_integration(hass, MockModule(entry_domain)) + entry = MockConfigEntry( + domain=entry_domain, + discovery_keys=entry_discovery_keys, + unique_id=entry_unique_id, + state=config_entries.ConfigEntryState.LOADED, + source=entry_source, + ) + entry.add_to_hass(hass) + + mock_ssdp_search_response = _ssdp_headers( + { + "st": "mock-st", + "location": "http://1.1.1.1", + "usn": "uuid:mock-udn::mock-st", + "server": "mock-server", + "ext": "", + "_source": "search", + } + ) + ssdp_listener = await init_ssdp_component(hass) + ssdp_listener._on_search(mock_ssdp_search_response) + await hass.async_block_till_done() + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + expected_context = { + "discovery_key": DiscoveryKey(domain="ssdp", key="uuid:mock-udn", version=1), + "source": config_entries.SOURCE_SSDP, + } + assert len(mock_flow_init.mock_calls) == 1 + assert mock_flow_init.mock_calls[0][1][0] == "mock-domain" + assert mock_flow_init.mock_calls[0][2]["context"] == expected_context + mock_call_data: ssdp.SsdpServiceInfo = mock_flow_init.mock_calls[0][2]["data"] + assert mock_call_data.ssdp_st == "mock-st" + assert mock_call_data.ssdp_location == "http://1.1.1.1" + + await hass.config_entries.async_remove(entry.entry_id) + await hass.async_block_till_done() + + assert len(mock_flow_init.mock_calls) == 2 + assert mock_flow_init.mock_calls[1][1][0] == entry_domain + assert mock_flow_init.mock_calls[1][2]["context"] == {"source": "unignore"}