Reinitialize dhcp discovery flow on config entry removal (#126556)

* Reinitialize dhcp discovery flow on unignore

* Tweak

* Rediscover on any removed config entry

* Adjust log message
This commit is contained in:
Erik Montnemery 2024-09-24 14:43:05 +02:00 committed by GitHub
parent b6fe3a3022
commit 972dc89c0f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 375 additions and 19 deletions

View file

@ -51,6 +51,7 @@ from homeassistant.helpers import (
discovery_flow,
)
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, format_mac
from homeassistant.helpers.discovery_flow import DiscoveryKey
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.event import (
async_track_state_added_domain,
@ -155,6 +156,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
await dhcp_watcher.async_start()
watchers.append(dhcp_watcher)
rediscovery_watcher = RediscoveryWatcher(
hass, address_data, integration_matchers
)
rediscovery_watcher.async_start()
watchers.append(rediscovery_watcher)
@callback
def _async_stop(event: Event) -> None:
for watcher in watchers:
@ -192,7 +199,11 @@ class WatcherBase:
@callback
def async_process_client(
self, ip_address: str, hostname: str, unformatted_mac_address: str
self,
ip_address: str,
hostname: str,
unformatted_mac_address: str,
force: bool = False,
) -> None:
"""Process a client."""
if (made_ip_address := cached_ip_addresses(ip_address)) is None:
@ -217,7 +228,8 @@ class WatcherBase:
data = self._address_data.get(mac_address)
if (
data
not force
and data
and data[IP_ADDRESS] == compressed_ip_address
and data[HOSTNAME].startswith(hostname)
):
@ -271,6 +283,14 @@ class WatcherBase:
_LOGGER.debug("Matched %s against %s", data, matcher)
matched_domains.add(domain)
if not matched_domains:
return # avoid creating DiscoveryKey if there are no matches
discovery_key = DiscoveryKey(
domain=DOMAIN,
key=mac_address,
version=1,
)
for domain in matched_domains:
discovery_flow.async_create_flow(
self.hass,
@ -281,6 +301,7 @@ class WatcherBase:
hostname=lowercase_hostname,
macaddress=mac_address,
),
discovery_key=discovery_key,
)
@ -414,6 +435,38 @@ class DHCPWatcher(WatcherBase):
self._unsub = await aiodhcpwatcher.async_start(self._async_process_dhcp_request)
class RediscoveryWatcher(WatcherBase):
"""Class to trigger rediscovery on config entry removal."""
@callback
def _handle_config_entry_removed(
self,
entry: config_entries.ConfigEntry,
) -> None:
"""Handle config entry changes."""
for discovery_key in entry.discovery_keys[DOMAIN]:
if discovery_key.version != 1 or not isinstance(discovery_key.key, str):
continue
mac_address = discovery_key.key
_LOGGER.debug("Rediscover service %s", mac_address)
if data := self._address_data.get(mac_address):
self.async_process_client(
data[IP_ADDRESS],
data[HOSTNAME],
mac_address,
True, # Force rediscovery
)
@callback
def async_start(self) -> None:
"""Start watching for config entry removals."""
self._unsub = async_dispatcher_connect(
self.hass,
config_entries.signal_discovered_config_entry_removed(DOMAIN),
self._handle_config_entry_removed,
)
@lru_cache(maxsize=4096, typed=True)
def _compile_fnmatch(pattern: str) -> re.Pattern:
"""Compile a fnmatch pattern."""

View file

@ -35,11 +35,17 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
import homeassistant.helpers.device_registry as dr
from homeassistant.helpers.discovery_flow import DiscoveryKey
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.setup import async_setup_component
import homeassistant.util.dt as dt_util
from tests.common import MockConfigEntry, async_fire_time_changed
from tests.common import (
MockConfigEntry,
MockModule,
async_fire_time_changed,
mock_integration,
)
# connect b8:b7:f1:6d:b5:33 192.168.210.56
RAW_DHCP_REQUEST = (
@ -138,11 +144,15 @@ RAW_DHCP_REQUEST_WITHOUT_HOSTNAME = (
async def _async_get_handle_dhcp_packet(
hass: HomeAssistant, integration_matchers: dhcp.DhcpMatchers
hass: HomeAssistant,
integration_matchers: dhcp.DhcpMatchers,
address_data: dict | None = None,
) -> Callable[[Any], Awaitable[None]]:
if address_data is None:
address_data = {}
dhcp_watcher = dhcp.DHCPWatcher(
hass,
{},
address_data,
integration_matchers,
)
with patch("aiodhcpwatcher.async_start"):
@ -177,7 +187,8 @@ async def test_dhcp_match_hostname_and_macaddress(hass: HomeAssistant) -> None:
assert len(mock_init.mock_calls) == 1
assert mock_init.mock_calls[0][1][0] == "mock-domain"
assert mock_init.mock_calls[0][2]["context"] == {
"source": config_entries.SOURCE_DHCP
"discovery_key": DiscoveryKey(domain="dhcp", key="b8b7f16db533", version=1),
"source": config_entries.SOURCE_DHCP,
}
assert mock_init.mock_calls[0][2]["data"] == dhcp.DhcpServiceInfo(
ip="192.168.210.56",
@ -205,7 +216,8 @@ async def test_dhcp_renewal_match_hostname_and_macaddress(hass: HomeAssistant) -
assert len(mock_init.mock_calls) == 1
assert mock_init.mock_calls[0][1][0] == "mock-domain"
assert mock_init.mock_calls[0][2]["context"] == {
"source": config_entries.SOURCE_DHCP
"discovery_key": DiscoveryKey(domain="dhcp", key="50147903852c", version=1),
"source": config_entries.SOURCE_DHCP,
}
assert mock_init.mock_calls[0][2]["data"] == dhcp.DhcpServiceInfo(
ip="192.168.1.120",
@ -254,7 +266,8 @@ async def test_registered_devices(
assert len(mock_init.mock_calls) == 1
assert mock_init.mock_calls[0][1][0] == "mock-domain"
assert mock_init.mock_calls[0][2]["context"] == {
"source": config_entries.SOURCE_DHCP
"discovery_key": DiscoveryKey(domain="dhcp", key="50147903852c", version=1),
"source": config_entries.SOURCE_DHCP,
}
assert mock_init.mock_calls[0][2]["data"] == dhcp.DhcpServiceInfo(
ip="192.168.1.120",
@ -280,7 +293,8 @@ async def test_dhcp_match_hostname(hass: HomeAssistant) -> None:
assert len(mock_init.mock_calls) == 1
assert mock_init.mock_calls[0][1][0] == "mock-domain"
assert mock_init.mock_calls[0][2]["context"] == {
"source": config_entries.SOURCE_DHCP
"discovery_key": DiscoveryKey(domain="dhcp", key="b8b7f16db533", version=1),
"source": config_entries.SOURCE_DHCP,
}
assert mock_init.mock_calls[0][2]["data"] == dhcp.DhcpServiceInfo(
ip="192.168.210.56",
@ -306,7 +320,8 @@ async def test_dhcp_match_macaddress(hass: HomeAssistant) -> None:
assert len(mock_init.mock_calls) == 1
assert mock_init.mock_calls[0][1][0] == "mock-domain"
assert mock_init.mock_calls[0][2]["context"] == {
"source": config_entries.SOURCE_DHCP
"discovery_key": DiscoveryKey(domain="dhcp", key="b8b7f16db533", version=1),
"source": config_entries.SOURCE_DHCP,
}
assert mock_init.mock_calls[0][2]["data"] == dhcp.DhcpServiceInfo(
ip="192.168.210.56",
@ -335,7 +350,8 @@ async def test_dhcp_multiple_match_only_one_flow(hass: HomeAssistant) -> None:
assert len(mock_init.mock_calls) == 1
assert mock_init.mock_calls[0][1][0] == "mock-domain"
assert mock_init.mock_calls[0][2]["context"] == {
"source": config_entries.SOURCE_DHCP
"discovery_key": DiscoveryKey(domain="dhcp", key="b8b7f16db533", version=1),
"source": config_entries.SOURCE_DHCP,
}
assert mock_init.mock_calls[0][2]["data"] == dhcp.DhcpServiceInfo(
ip="192.168.210.56",
@ -361,7 +377,8 @@ async def test_dhcp_match_macaddress_without_hostname(hass: HomeAssistant) -> No
assert len(mock_init.mock_calls) == 1
assert mock_init.mock_calls[0][1][0] == "mock-domain"
assert mock_init.mock_calls[0][2]["context"] == {
"source": config_entries.SOURCE_DHCP
"discovery_key": DiscoveryKey(domain="dhcp", key="606bbd59e4b4", version=1),
"source": config_entries.SOURCE_DHCP,
}
assert mock_init.mock_calls[0][2]["data"] == dhcp.DhcpServiceInfo(
ip="192.168.107.151",
@ -687,7 +704,8 @@ async def test_device_tracker_hostname_and_macaddress_exists_before_start(
assert len(mock_init.mock_calls) == 1
assert mock_init.mock_calls[0][1][0] == "mock-domain"
assert mock_init.mock_calls[0][2]["context"] == {
"source": config_entries.SOURCE_DHCP
"discovery_key": DiscoveryKey(domain="dhcp", key="b8b7f16db533", version=1),
"source": config_entries.SOURCE_DHCP,
}
assert mock_init.mock_calls[0][2]["data"] == dhcp.DhcpServiceInfo(
ip="192.168.210.56",
@ -724,7 +742,8 @@ async def test_device_tracker_registered(hass: HomeAssistant) -> None:
assert len(mock_init.mock_calls) == 1
assert mock_init.mock_calls[0][1][0] == "mock-domain"
assert mock_init.mock_calls[0][2]["context"] == {
"source": config_entries.SOURCE_DHCP
"discovery_key": DiscoveryKey(domain="dhcp", key="b8b7f16db533", version=1),
"source": config_entries.SOURCE_DHCP,
}
assert mock_init.mock_calls[0][2]["data"] == dhcp.DhcpServiceInfo(
ip="192.168.210.56",
@ -803,7 +822,8 @@ async def test_device_tracker_hostname_and_macaddress_after_start(
assert len(mock_init.mock_calls) == 1
assert mock_init.mock_calls[0][1][0] == "mock-domain"
assert mock_init.mock_calls[0][2]["context"] == {
"source": config_entries.SOURCE_DHCP
"discovery_key": DiscoveryKey(domain="dhcp", key="b8b7f16db533", version=1),
"source": config_entries.SOURCE_DHCP,
}
assert mock_init.mock_calls[0][2]["data"] == dhcp.DhcpServiceInfo(
ip="192.168.210.56",
@ -1012,7 +1032,8 @@ async def test_aiodiscover_finds_new_hosts(hass: HomeAssistant) -> None:
assert len(mock_init.mock_calls) == 1
assert mock_init.mock_calls[0][1][0] == "mock-domain"
assert mock_init.mock_calls[0][2]["context"] == {
"source": config_entries.SOURCE_DHCP
"discovery_key": DiscoveryKey(domain="dhcp", key="b8b7f16db533", version=1),
"source": config_entries.SOURCE_DHCP,
}
assert mock_init.mock_calls[0][2]["data"] == dhcp.DhcpServiceInfo(
ip="192.168.210.56",
@ -1074,7 +1095,8 @@ async def test_aiodiscover_does_not_call_again_on_shorter_hostname(
assert len(mock_init.mock_calls) == 2
assert mock_init.mock_calls[0][1][0] == "mock-domain"
assert mock_init.mock_calls[0][2]["context"] == {
"source": config_entries.SOURCE_DHCP
"discovery_key": DiscoveryKey(domain="dhcp", key="b8b7f16db533", version=1),
"source": config_entries.SOURCE_DHCP,
}
assert mock_init.mock_calls[0][2]["data"] == dhcp.DhcpServiceInfo(
ip="192.168.210.56",
@ -1083,7 +1105,8 @@ async def test_aiodiscover_does_not_call_again_on_shorter_hostname(
)
assert mock_init.mock_calls[1][1][0] == "mock-domain"
assert mock_init.mock_calls[1][2]["context"] == {
"source": config_entries.SOURCE_DHCP
"discovery_key": DiscoveryKey(domain="dhcp", key="b8b7f16db533", version=1),
"source": config_entries.SOURCE_DHCP,
}
assert mock_init.mock_calls[1][2]["data"] == dhcp.DhcpServiceInfo(
ip="192.168.210.56",
@ -1140,10 +1163,290 @@ async def test_aiodiscover_finds_new_hosts_after_interval(hass: HomeAssistant) -
assert len(mock_init.mock_calls) == 1
assert mock_init.mock_calls[0][1][0] == "mock-domain"
assert mock_init.mock_calls[0][2]["context"] == {
"source": config_entries.SOURCE_DHCP
"discovery_key": DiscoveryKey(domain="dhcp", key="b8b7f16db533", version=1),
"source": config_entries.SOURCE_DHCP,
}
assert mock_init.mock_calls[0][2]["data"] == dhcp.DhcpServiceInfo(
ip="192.168.210.56",
hostname="connect",
macaddress="b8b7f16db533",
)
@pytest.mark.parametrize(
(
"entry_domain",
"entry_discovery_keys",
),
[
# Matching discovery key
(
"mock-domain",
{"dhcp": (DiscoveryKey(domain="dhcp", key="b8b7f16db533", version=1),)},
),
# Matching discovery key
(
"mock-domain",
{
"dhcp": (DiscoveryKey(domain="dhcp", key="b8b7f16db533", 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",
{"dhcp": (DiscoveryKey(domain="dhcp", key="b8b7f16db533", version=1),)},
),
],
)
@pytest.mark.parametrize("entry_source", [config_entries.SOURCE_IGNORE])
async def test_dhcp_rediscover(
hass: HomeAssistant,
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)
address_data = {}
integration_matchers = dhcp.async_index_integration_matchers(
[{"domain": "mock-domain", "hostname": "connect", "macaddress": "B8B7F1*"}]
)
packet = Ether(RAW_DHCP_REQUEST)
async_handle_dhcp_packet = await _async_get_handle_dhcp_packet(
hass, integration_matchers, address_data
)
rediscovery_watcher = dhcp.RediscoveryWatcher(
hass, address_data, integration_matchers
)
rediscovery_watcher.async_start()
with patch.object(hass.config_entries.flow, "async_init") as mock_init:
await async_handle_dhcp_packet(packet)
# Ensure no change is ignored
await async_handle_dhcp_packet(packet)
# Assert the cached MAC address is hexstring without :
assert address_data == {
"b8b7f16db533": {"hostname": "connect", "ip": "192.168.210.56"}
}
expected_context = {
"discovery_key": DiscoveryKey(domain="dhcp", key="b8b7f16db533", version=1),
"source": config_entries.SOURCE_DHCP,
}
assert len(mock_init.mock_calls) == 1
assert mock_init.mock_calls[0][1][0] == "mock-domain"
assert mock_init.mock_calls[0][2]["context"] == expected_context
assert mock_init.mock_calls[0][2]["data"] == dhcp.DhcpServiceInfo(
ip="192.168.210.56",
hostname="connect",
macaddress="b8b7f16db533",
)
with patch.object(hass.config_entries.flow, "async_init") as mock_init:
await hass.config_entries.async_remove(entry.entry_id)
await hass.async_block_till_done()
assert len(mock_init.mock_calls) == 2
assert mock_init.mock_calls[0][1][0] == entry_domain
assert mock_init.mock_calls[0][2]["context"] == {"source": "unignore"}
assert mock_init.mock_calls[1][1][0] == "mock-domain"
assert mock_init.mock_calls[1][2]["context"] == expected_context
@pytest.mark.parametrize(
(
"entry_domain",
"entry_discovery_keys",
),
[
# Matching discovery key
(
"mock-domain",
{"dhcp": (DiscoveryKey(domain="dhcp", key="b8b7f16db533", version=1),)},
),
# Matching discovery key
(
"mock-domain",
{
"dhcp": (DiscoveryKey(domain="dhcp", key="b8b7f16db533", 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",
{"dhcp": (DiscoveryKey(domain="dhcp", key="b8b7f16db533", version=1),)},
),
],
)
@pytest.mark.parametrize(
"entry_source", [config_entries.SOURCE_USER, config_entries.SOURCE_ZEROCONF]
)
async def test_dhcp_rediscover_2(
hass: HomeAssistant,
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)
address_data = {}
integration_matchers = dhcp.async_index_integration_matchers(
[{"domain": "mock-domain", "hostname": "connect", "macaddress": "B8B7F1*"}]
)
packet = Ether(RAW_DHCP_REQUEST)
async_handle_dhcp_packet = await _async_get_handle_dhcp_packet(
hass, integration_matchers, address_data
)
rediscovery_watcher = dhcp.RediscoveryWatcher(
hass, address_data, integration_matchers
)
rediscovery_watcher.async_start()
with patch.object(hass.config_entries.flow, "async_init") as mock_init:
await async_handle_dhcp_packet(packet)
# Ensure no change is ignored
await async_handle_dhcp_packet(packet)
# Assert the cached MAC address is hexstring without :
assert address_data == {
"b8b7f16db533": {"hostname": "connect", "ip": "192.168.210.56"}
}
expected_context = {
"discovery_key": DiscoveryKey(domain="dhcp", key="b8b7f16db533", version=1),
"source": config_entries.SOURCE_DHCP,
}
assert len(mock_init.mock_calls) == 1
assert mock_init.mock_calls[0][1][0] == "mock-domain"
assert mock_init.mock_calls[0][2]["context"] == expected_context
assert mock_init.mock_calls[0][2]["data"] == dhcp.DhcpServiceInfo(
ip="192.168.210.56",
hostname="connect",
macaddress="b8b7f16db533",
)
with patch.object(hass.config_entries.flow, "async_init") as mock_init:
await hass.config_entries.async_remove(entry.entry_id)
await hass.async_block_till_done()
assert len(mock_init.mock_calls) == 1
assert mock_init.mock_calls[0][1][0] == "mock-domain"
assert mock_init.mock_calls[0][2]["context"] == expected_context
@pytest.mark.usefixtures("mock_async_zeroconf")
@pytest.mark.parametrize(
(
"entry_domain",
"entry_discovery_keys",
"entry_source",
"entry_unique_id",
),
[
# Discovery key from other domain
(
"mock-domain",
{
"bluetooth": (
DiscoveryKey(domain="bluetooth", key="b8b7f16db533", version=1),
)
},
config_entries.SOURCE_IGNORE,
"mock-unique-id",
),
# Discovery key from the future
(
"mock-domain",
{"dhcp": (DiscoveryKey(domain="dhcp", key="b8b7f16db533", version=2),)},
config_entries.SOURCE_IGNORE,
"mock-unique-id",
),
],
)
async def test_dhcp_rediscover_no_match(
hass: HomeAssistant,
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)
address_data = {}
integration_matchers = dhcp.async_index_integration_matchers(
[{"domain": "mock-domain", "hostname": "connect", "macaddress": "B8B7F1*"}]
)
packet = Ether(RAW_DHCP_REQUEST)
async_handle_dhcp_packet = await _async_get_handle_dhcp_packet(
hass, integration_matchers, address_data
)
rediscovery_watcher = dhcp.RediscoveryWatcher(
hass, address_data, integration_matchers
)
rediscovery_watcher.async_start()
with patch.object(hass.config_entries.flow, "async_init") as mock_init:
await async_handle_dhcp_packet(packet)
# Ensure no change is ignored
await async_handle_dhcp_packet(packet)
expected_context = {
"discovery_key": DiscoveryKey(domain="dhcp", key="b8b7f16db533", version=1),
"source": config_entries.SOURCE_DHCP,
}
assert len(mock_init.mock_calls) == 1
assert mock_init.mock_calls[0][1][0] == "mock-domain"
assert mock_init.mock_calls[0][2]["context"] == expected_context
assert mock_init.mock_calls[0][2]["data"] == dhcp.DhcpServiceInfo(
ip="192.168.210.56",
hostname="connect",
macaddress="b8b7f16db533",
)
with patch.object(hass.config_entries.flow, "async_init") as mock_init:
await hass.config_entries.async_remove(entry.entry_id)
await hass.async_block_till_done()
assert len(mock_init.mock_calls) == 1
assert mock_init.mock_calls[0][1][0] == entry_domain
assert mock_init.mock_calls[0][2]["context"] == {"source": "unignore"}