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"}