From 972dc89c0f5046e6ada78e957179a577492b3663 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 24 Sep 2024 14:43:05 +0200 Subject: [PATCH] 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 --- homeassistant/components/dhcp/__init__.py | 57 +++- tests/components/dhcp/test_init.py | 337 ++++++++++++++++++++-- 2 files changed, 375 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/dhcp/__init__.py b/homeassistant/components/dhcp/__init__.py index bf3389b4111..2de676ef52a 100644 --- a/homeassistant/components/dhcp/__init__.py +++ b/homeassistant/components/dhcp/__init__.py @@ -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.""" diff --git a/tests/components/dhcp/test_init.py b/tests/components/dhcp/test_init.py index 7c652c8ea3e..3916a854247 100644 --- a/tests/components/dhcp/test_init.py +++ b/tests/components/dhcp/test_init.py @@ -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"}