Reinitialize ssdp discovery flow on unignore (#126557)

This commit is contained in:
Erik Montnemery 2024-09-24 17:38:33 +02:00 committed by GitHub
parent 4e465a2066
commit 2ee93d974d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 402 additions and 20 deletions

View file

@ -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,

View file

@ -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="""
<root>
<device>
<deviceType>Paulus</deviceType>
<manufacturer>Paulus</manufacturer>
</device>
</root>
""",
)
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="""
<root>
<device>
<deviceType>Paulus</deviceType>
<manufacturer>Paulus</manufacturer>
</device>
</root>
""",
)
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"}