Add additional data source to dhcp (#48430)

This commit is contained in:
J. Nick Koston 2021-03-28 09:47:28 -10:00 committed by GitHub
parent 23c7c4c977
commit 2ff94c8ed9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 235 additions and 60 deletions

View File

@ -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):

View File

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

View File

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

View File

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

View File

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

View File

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