diff --git a/homeassistant/components/dhcp/__init__.py b/homeassistant/components/dhcp/__init__.py index b9a330510c1..d184ec40383 100644 --- a/homeassistant/components/dhcp/__init__.py +++ b/homeassistant/components/dhcp/__init__.py @@ -1,12 +1,19 @@ """The dhcp integration.""" from abc import abstractmethod +from datetime import timedelta import fnmatch from ipaddress import ip_address as make_ip_address import logging import os import threading +from aiodiscover import DiscoverHosts +from aiodiscover.discovery import ( + HOSTNAME as DISCOVERY_HOSTNAME, + IP_ADDRESS as DISCOVERY_IP_ADDRESS, + MAC_ADDRESS as DISCOVERY_MAC_ADDRESS, +) from scapy.arch.common import compile_filter from scapy.config import conf from scapy.error import Scapy_Exception @@ -29,7 +36,10 @@ from homeassistant.const import ( ) from homeassistant.core import Event, HomeAssistant, State, callback from homeassistant.helpers.device_registry import format_mac -from homeassistant.helpers.event import async_track_state_added_domain +from homeassistant.helpers.event import ( + async_track_state_added_domain, + async_track_time_interval, +) from homeassistant.loader import async_get_dhcp from homeassistant.util.network import is_link_local @@ -42,6 +52,7 @@ HOSTNAME = "hostname" MAC_ADDRESS = "macaddress" IP_ADDRESS = "ip" DHCP_REQUEST = 3 +SCAN_INTERVAL = timedelta(minutes=60) _LOGGER = logging.getLogger(__name__) @@ -54,7 +65,7 @@ async def async_setup(hass: HomeAssistant, config: dict) -> bool: integration_matchers = await async_get_dhcp(hass) watchers = [] - for cls in (DHCPWatcher, DeviceTrackerWatcher): + for cls in (DHCPWatcher, DeviceTrackerWatcher, NetworkWatcher): watcher = cls(hass, address_data, integration_matchers) await watcher.async_start() watchers.append(watcher) @@ -88,7 +99,11 @@ class WatcherBase: data = self._address_data.get(ip_address) - if data and data[MAC_ADDRESS] == mac_address and data[HOSTNAME] == hostname: + if ( + data + and data[MAC_ADDRESS] == mac_address + and data[HOSTNAME].startswith(hostname) + ): # If the address data is the same no need # to process it return @@ -139,6 +154,54 @@ class WatcherBase: """Pass a task to async_add_task based on which context we are in.""" +class NetworkWatcher(WatcherBase): + """Class to query ptr records routers.""" + + def __init__(self, hass, address_data, integration_matchers): + """Initialize class.""" + super().__init__(hass, address_data, integration_matchers) + self._unsub = None + self._discover_hosts = None + self._discover_task = None + + async def async_stop(self): + """Stop scanning for new devices on the network.""" + if self._unsub: + self._unsub() + self._unsub = None + if self._discover_task: + self._discover_task.cancel() + self._discover_task = None + + async def async_start(self): + """Start scanning for new devices on the network.""" + self._discover_hosts = DiscoverHosts() + self._unsub = async_track_time_interval( + self.hass, self.async_start_discover, SCAN_INTERVAL + ) + self.async_start_discover() + + @callback + def async_start_discover(self, *_): + """Start a new discovery task if one is not running.""" + if self._discover_task and not self._discover_task.done(): + return + self._discover_task = self.create_task(self.async_discover()) + + async def async_discover(self): + """Process discovery.""" + for host in await self._discover_hosts.async_discover(): + self.process_client( + host[DISCOVERY_IP_ADDRESS], + host[DISCOVERY_HOSTNAME], + _format_mac(host[DISCOVERY_MAC_ADDRESS]), + ) + + def create_task(self, task): + """Pass a task to async_create_task since we are in async context.""" + return self.hass.async_create_task(task) + + class DeviceTrackerWatcher(WatcherBase): """Class to watch dhcp data from routers.""" @@ -188,7 +251,7 @@ class DeviceTrackerWatcher(WatcherBase): def create_task(self, task): """Pass a task to async_create_task since we are in async context.""" - self.hass.async_create_task(task) + return self.hass.async_create_task(task) class DHCPWatcher(WatcherBase): @@ -266,7 +329,7 @@ class DHCPWatcher(WatcherBase): def create_task(self, task): """Pass a task to hass.add_job since we are in a thread.""" - self.hass.add_job(task) + return self.hass.add_job(task) def _decode_dhcp_option(dhcp_options, key): diff --git a/homeassistant/components/dhcp/manifest.json b/homeassistant/components/dhcp/manifest.json index eda229ebec7..ea1e7ae3c43 100644 --- a/homeassistant/components/dhcp/manifest.json +++ b/homeassistant/components/dhcp/manifest.json @@ -3,7 +3,7 @@ "name": "DHCP Discovery", "documentation": "https://www.home-assistant.io/integrations/dhcp", "requirements": [ - "scapy==2.4.4" + "scapy==2.4.4", "aiodiscover==1.1.0" ], "codeowners": [ "@bdraco" diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 4d1d53b85d1..27e850613eb 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -1,5 +1,6 @@ PyJWT==1.7.1 PyNaCl==1.3.0 +aiodiscover==1.1.0 aiohttp==3.7.4.post0 aiohttp_cors==0.7.0 astral==1.10.1 diff --git a/requirements_all.txt b/requirements_all.txt index 7e4842ac106..773b6c7e2a9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -146,6 +146,9 @@ aioazuredevops==1.3.5 # homeassistant.components.aws aiobotocore==0.11.1 +# homeassistant.components.dhcp +aiodiscover==1.1.0 + # homeassistant.components.dnsip # homeassistant.components.minecraft_server aiodns==2.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b670eaa2050..a96a06d8958 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -83,6 +83,9 @@ aioazuredevops==1.3.5 # homeassistant.components.aws aiobotocore==0.11.1 +# homeassistant.components.dhcp +aiodiscover==1.1.0 + # homeassistant.components.dnsip # homeassistant.components.minecraft_server aiodns==2.0.0 diff --git a/tests/components/dhcp/test_init.py b/tests/components/dhcp/test_init.py index de6743719c6..69e15104092 100644 --- a/tests/components/dhcp/test_init.py +++ b/tests/components/dhcp/test_init.py @@ -1,4 +1,5 @@ """Test the DHCP discovery integration.""" +import datetime import threading from unittest.mock import patch @@ -21,8 +22,9 @@ from homeassistant.const import ( STATE_NOT_HOME, ) from homeassistant.setup import async_setup_component +import homeassistant.util.dt as dt_util -from tests.common import mock_coro +from tests.common import async_fire_time_changed # connect b8:b7:f1:6d:b5:33 192.168.210.56 RAW_DHCP_REQUEST = ( @@ -59,9 +61,7 @@ async def test_dhcp_match_hostname_and_macaddress(hass): packet = Ether(RAW_DHCP_REQUEST) - with patch.object( - hass.config_entries.flow, "async_init", return_value=mock_coro() - ) as mock_init: + with patch.object(hass.config_entries.flow, "async_init") as mock_init: dhcp_watcher.handle_dhcp_packet(packet) # Ensure no change is ignored dhcp_watcher.handle_dhcp_packet(packet) @@ -84,9 +84,7 @@ async def test_dhcp_match_hostname(hass): packet = Ether(RAW_DHCP_REQUEST) - with patch.object( - hass.config_entries.flow, "async_init", return_value=mock_coro() - ) as mock_init: + with patch.object(hass.config_entries.flow, "async_init") as mock_init: dhcp_watcher.handle_dhcp_packet(packet) assert len(mock_init.mock_calls) == 1 @@ -107,9 +105,7 @@ async def test_dhcp_match_macaddress(hass): packet = Ether(RAW_DHCP_REQUEST) - with patch.object( - hass.config_entries.flow, "async_init", return_value=mock_coro() - ) as mock_init: + with patch.object(hass.config_entries.flow, "async_init") as mock_init: dhcp_watcher.handle_dhcp_packet(packet) assert len(mock_init.mock_calls) == 1 @@ -130,9 +126,7 @@ async def test_dhcp_nomatch(hass): packet = Ether(RAW_DHCP_REQUEST) - with patch.object( - hass.config_entries.flow, "async_init", return_value=mock_coro() - ) as mock_init: + with patch.object(hass.config_entries.flow, "async_init") as mock_init: dhcp_watcher.handle_dhcp_packet(packet) assert len(mock_init.mock_calls) == 0 @@ -146,9 +140,7 @@ async def test_dhcp_nomatch_hostname(hass): packet = Ether(RAW_DHCP_REQUEST) - with patch.object( - hass.config_entries.flow, "async_init", return_value=mock_coro() - ) as mock_init: + with patch.object(hass.config_entries.flow, "async_init") as mock_init: dhcp_watcher.handle_dhcp_packet(packet) assert len(mock_init.mock_calls) == 0 @@ -162,9 +154,7 @@ async def test_dhcp_nomatch_non_dhcp_packet(hass): packet = Ether(b"") - with patch.object( - hass.config_entries.flow, "async_init", return_value=mock_coro() - ) as mock_init: + with patch.object(hass.config_entries.flow, "async_init") as mock_init: dhcp_watcher.handle_dhcp_packet(packet) assert len(mock_init.mock_calls) == 0 @@ -187,9 +177,7 @@ async def test_dhcp_nomatch_non_dhcp_request_packet(hass): ("hostname", b"connect"), ] - with patch.object( - hass.config_entries.flow, "async_init", return_value=mock_coro() - ) as mock_init: + with patch.object(hass.config_entries.flow, "async_init") as mock_init: dhcp_watcher.handle_dhcp_packet(packet) assert len(mock_init.mock_calls) == 0 @@ -212,9 +200,7 @@ async def test_dhcp_invalid_hostname(hass): ("hostname", "connect"), ] - with patch.object( - hass.config_entries.flow, "async_init", return_value=mock_coro() - ) as mock_init: + with patch.object(hass.config_entries.flow, "async_init") as mock_init: dhcp_watcher.handle_dhcp_packet(packet) assert len(mock_init.mock_calls) == 0 @@ -237,9 +223,7 @@ async def test_dhcp_missing_hostname(hass): ("hostname", None), ] - with patch.object( - hass.config_entries.flow, "async_init", return_value=mock_coro() - ) as mock_init: + with patch.object(hass.config_entries.flow, "async_init") as mock_init: dhcp_watcher.handle_dhcp_packet(packet) assert len(mock_init.mock_calls) == 0 @@ -262,9 +246,7 @@ async def test_dhcp_invalid_option(hass): ("hostname"), ] - with patch.object( - hass.config_entries.flow, "async_init", return_value=mock_coro() - ) as mock_init: + with patch.object(hass.config_entries.flow, "async_init") as mock_init: dhcp_watcher.handle_dhcp_packet(packet) assert len(mock_init.mock_calls) == 0 @@ -282,8 +264,8 @@ async def test_setup_and_stop(hass): with patch("homeassistant.components.dhcp.AsyncSniffer.start") as start_call, patch( "homeassistant.components.dhcp._verify_l2socket_setup", - ), patch( - "homeassistant.components.dhcp.compile_filter", + ), patch("homeassistant.components.dhcp.compile_filter",), patch( + "homeassistant.components.dhcp.DiscoverHosts.async_discover" ): hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() @@ -309,7 +291,7 @@ async def test_setup_fails_as_root(hass, caplog): with patch("os.geteuid", return_value=0), patch( "homeassistant.components.dhcp._verify_l2socket_setup", side_effect=Scapy_Exception, - ): + ), patch("homeassistant.components.dhcp.DiscoverHosts.async_discover"): hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() @@ -332,7 +314,7 @@ async def test_setup_fails_non_root(hass, caplog): with patch("os.geteuid", return_value=10), patch( "homeassistant.components.dhcp._verify_l2socket_setup", side_effect=Scapy_Exception, - ): + ), patch("homeassistant.components.dhcp.DiscoverHosts.async_discover"): hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) @@ -356,7 +338,9 @@ async def test_setup_fails_with_broken_libpcap(hass, caplog): side_effect=ImportError, ) as compile_filter, patch( "homeassistant.components.dhcp.AsyncSniffer", - ) as async_sniffer: + ) as async_sniffer, patch( + "homeassistant.components.dhcp.DiscoverHosts.async_discover" + ): hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) @@ -383,9 +367,7 @@ async def test_device_tracker_hostname_and_macaddress_exists_before_start(hass): }, ) - with patch.object( - hass.config_entries.flow, "async_init", return_value=mock_coro() - ) as mock_init: + with patch.object(hass.config_entries.flow, "async_init") as mock_init: device_tracker_watcher = dhcp.DeviceTrackerWatcher( hass, {}, @@ -409,9 +391,7 @@ async def test_device_tracker_hostname_and_macaddress_exists_before_start(hass): async def test_device_tracker_hostname_and_macaddress_after_start(hass): """Test matching based on hostname and macaddress after start.""" - with patch.object( - hass.config_entries.flow, "async_init", return_value=mock_coro() - ) as mock_init: + with patch.object(hass.config_entries.flow, "async_init") as mock_init: device_tracker_watcher = dhcp.DeviceTrackerWatcher( hass, {}, @@ -446,9 +426,7 @@ async def test_device_tracker_hostname_and_macaddress_after_start(hass): async def test_device_tracker_hostname_and_macaddress_after_start_not_home(hass): """Test matching based on hostname and macaddress after start but not home.""" - with patch.object( - hass.config_entries.flow, "async_init", return_value=mock_coro() - ) as mock_init: + with patch.object(hass.config_entries.flow, "async_init") as mock_init: device_tracker_watcher = dhcp.DeviceTrackerWatcher( hass, {}, @@ -476,9 +454,7 @@ async def test_device_tracker_hostname_and_macaddress_after_start_not_home(hass) async def test_device_tracker_hostname_and_macaddress_after_start_not_router(hass): """Test matching based on hostname and macaddress after start but not router.""" - with patch.object( - hass.config_entries.flow, "async_init", return_value=mock_coro() - ) as mock_init: + with patch.object(hass.config_entries.flow, "async_init") as mock_init: device_tracker_watcher = dhcp.DeviceTrackerWatcher( hass, {}, @@ -508,9 +484,7 @@ async def test_device_tracker_hostname_and_macaddress_after_start_hostname_missi ): """Test matching based on hostname and macaddress after start but missing hostname.""" - with patch.object( - hass.config_entries.flow, "async_init", return_value=mock_coro() - ) as mock_init: + with patch.object(hass.config_entries.flow, "async_init") as mock_init: device_tracker_watcher = dhcp.DeviceTrackerWatcher( hass, {}, @@ -547,9 +521,7 @@ async def test_device_tracker_ignore_self_assigned_ips_before_start(hass): }, ) - with patch.object( - hass.config_entries.flow, "async_init", return_value=mock_coro() - ) as mock_init: + with patch.object(hass.config_entries.flow, "async_init") as mock_init: device_tracker_watcher = dhcp.DeviceTrackerWatcher( hass, {}, @@ -561,3 +533,136 @@ async def test_device_tracker_ignore_self_assigned_ips_before_start(hass): await hass.async_block_till_done() assert len(mock_init.mock_calls) == 0 + + +async def test_aiodiscover_finds_new_hosts(hass): + """Test aiodiscover finds new host.""" + with patch.object(hass.config_entries.flow, "async_init") as mock_init, patch( + "homeassistant.components.dhcp.DiscoverHosts.async_discover", + return_value=[ + { + dhcp.DISCOVERY_IP_ADDRESS: "192.168.210.56", + dhcp.DISCOVERY_HOSTNAME: "connect", + dhcp.DISCOVERY_MAC_ADDRESS: "b8b7f16db533", + } + ], + ): + device_tracker_watcher = dhcp.NetworkWatcher( + hass, + {}, + [{"domain": "mock-domain", "hostname": "connect", "macaddress": "B8B7F1*"}], + ) + await device_tracker_watcher.async_start() + await hass.async_block_till_done() + await device_tracker_watcher.async_stop() + 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"] == {"source": "dhcp"} + assert mock_init.mock_calls[0][2]["data"] == { + dhcp.IP_ADDRESS: "192.168.210.56", + dhcp.HOSTNAME: "connect", + dhcp.MAC_ADDRESS: "b8b7f16db533", + } + + +async def test_aiodiscover_does_not_call_again_on_shorter_hostname(hass): + """Verify longer hostnames generate a new flow but shorter ones do not. + + Some routers will truncate hostnames so we want to accept + additional discovery where the hostname is longer and then + reject shorter ones. + """ + with patch.object(hass.config_entries.flow, "async_init") as mock_init, patch( + "homeassistant.components.dhcp.DiscoverHosts.async_discover", + return_value=[ + { + dhcp.DISCOVERY_IP_ADDRESS: "192.168.210.56", + dhcp.DISCOVERY_HOSTNAME: "irobot-abc", + dhcp.DISCOVERY_MAC_ADDRESS: "b8b7f16db533", + }, + { + dhcp.DISCOVERY_IP_ADDRESS: "192.168.210.56", + dhcp.DISCOVERY_HOSTNAME: "irobot-abcdef", + dhcp.DISCOVERY_MAC_ADDRESS: "b8b7f16db533", + }, + { + dhcp.DISCOVERY_IP_ADDRESS: "192.168.210.56", + dhcp.DISCOVERY_HOSTNAME: "irobot-abc", + dhcp.DISCOVERY_MAC_ADDRESS: "b8b7f16db533", + }, + ], + ): + device_tracker_watcher = dhcp.NetworkWatcher( + hass, + {}, + [ + { + "domain": "mock-domain", + "hostname": "irobot-*", + "macaddress": "B8B7F1*", + } + ], + ) + await device_tracker_watcher.async_start() + await hass.async_block_till_done() + await device_tracker_watcher.async_stop() + await hass.async_block_till_done() + + 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": "dhcp"} + assert mock_init.mock_calls[0][2]["data"] == { + dhcp.IP_ADDRESS: "192.168.210.56", + dhcp.HOSTNAME: "irobot-abc", + dhcp.MAC_ADDRESS: "b8b7f16db533", + } + assert mock_init.mock_calls[1][1][0] == "mock-domain" + assert mock_init.mock_calls[1][2]["context"] == {"source": "dhcp"} + assert mock_init.mock_calls[1][2]["data"] == { + dhcp.IP_ADDRESS: "192.168.210.56", + dhcp.HOSTNAME: "irobot-abcdef", + dhcp.MAC_ADDRESS: "b8b7f16db533", + } + + +async def test_aiodiscover_finds_new_hosts_after_interval(hass): + """Test aiodiscover finds new host after interval.""" + with patch.object(hass.config_entries.flow, "async_init") as mock_init, patch( + "homeassistant.components.dhcp.DiscoverHosts.async_discover", + return_value=[], + ): + device_tracker_watcher = dhcp.NetworkWatcher( + hass, + {}, + [{"domain": "mock-domain", "hostname": "connect", "macaddress": "B8B7F1*"}], + ) + await device_tracker_watcher.async_start() + await hass.async_block_till_done() + + assert len(mock_init.mock_calls) == 0 + + with patch.object(hass.config_entries.flow, "async_init") as mock_init, patch( + "homeassistant.components.dhcp.DiscoverHosts.async_discover", + return_value=[ + { + dhcp.DISCOVERY_IP_ADDRESS: "192.168.210.56", + dhcp.DISCOVERY_HOSTNAME: "connect", + dhcp.DISCOVERY_MAC_ADDRESS: "b8b7f16db533", + } + ], + ): + async_fire_time_changed(hass, dt_util.utcnow() + datetime.timedelta(minutes=65)) + await hass.async_block_till_done() + await device_tracker_watcher.async_stop() + 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"] == {"source": "dhcp"} + assert mock_init.mock_calls[0][2]["data"] == { + dhcp.IP_ADDRESS: "192.168.210.56", + dhcp.HOSTNAME: "connect", + dhcp.MAC_ADDRESS: "b8b7f16db533", + }