Add support for discovery via DHCP (#45087)

* Add support for discovery via DHCP

* additional tesla ouis

* merge tests

* dhcp test

* merge requirements test

* dhcp test

* dhcp discovery

* dhcp discovery

* pylint

* pylint

* pylint

* fix

* Add matching tests

* 100% cover

* cleanup

* fix codespell

* Update exception handling

* remove unneeded comment

* fix options handling exception

* fix options handling exception
This commit is contained in:
J. Nick Koston 2021-01-13 22:09:08 -10:00 committed by GitHub
parent 402a0ea7da
commit da677f7d5a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
38 changed files with 843 additions and 17 deletions

View file

@ -17,7 +17,7 @@ repos:
hooks:
- id: codespell
args:
- --ignore-words-list=hass,alot,datas,dof,dur,farenheit,hist,iff,ines,ist,lightsensor,mut,nd,pres,referer,ser,serie,te,technik,ue,uint,visability,wan,wanna,withing,iam,incomfort
- --ignore-words-list=hass,alot,datas,dof,dur,ether,farenheit,hist,iff,ines,ist,lightsensor,mut,nd,pres,referer,ser,serie,te,technik,ue,uint,visability,wan,wanna,withing,iam,incomfort
- --skip="./.*,*.csv,*.json"
- --quiet-level=2
exclude_types: [csv, json]

View file

@ -107,6 +107,7 @@ homeassistant/components/derivative/* @afaucogney
homeassistant/components/device_automation/* @home-assistant/core
homeassistant/components/devolo_home_control/* @2Fake @Shutgun
homeassistant/components/dexcom/* @gagebenne
homeassistant/components/dhcp/* @bdraco
homeassistant/components/digital_ocean/* @fabaff
homeassistant/components/directv/* @ctalkington
homeassistant/components/discogs/* @thibmaek

View file

@ -5,5 +5,9 @@
"requirements": ["py-august==0.25.2"],
"dependencies": ["configurator"],
"codeowners": ["@bdraco"],
"dhcp": [
{"hostname":"connect","macaddress":"D86162*"},
{"hostname":"connect","macaddress":"B8B7F1*"}
],
"config_flow": true
}

View file

@ -6,6 +6,7 @@
"automation",
"cloud",
"counter",
"dhcp",
"frontend",
"history",
"input_boolean",

View file

@ -0,0 +1,159 @@
"""The dhcp integration."""
import fnmatch
import logging
from threading import Event, Thread
from scapy.error import Scapy_Exception
from scapy.layers.dhcp import DHCP
from scapy.layers.l2 import Ether
from scapy.sendrecv import sniff
from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import format_mac
from homeassistant.loader import async_get_dhcp
from .const import DOMAIN
FILTER = "udp and (port 67 or 68)"
REQUESTED_ADDR = "requested_addr"
MESSAGE_TYPE = "message-type"
HOSTNAME = "hostname"
MAC_ADDRESS = "macaddress"
IP_ADDRESS = "ip"
DHCP_REQUEST = 3
_LOGGER = logging.getLogger(__name__)
async def async_setup(hass: HomeAssistant, config: dict) -> bool:
"""Set up the dhcp component."""
async def _initialize(_):
dhcp_watcher = DHCPWatcher(hass, await async_get_dhcp(hass))
dhcp_watcher.start()
def _stop(*_):
dhcp_watcher.stop()
dhcp_watcher.join()
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _stop)
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, _initialize)
return True
class DHCPWatcher(Thread):
"""Class to watch dhcp requests."""
def __init__(self, hass, integration_matchers):
"""Initialize class."""
super().__init__()
self.hass = hass
self.name = "dhcp-discovery"
self._integration_matchers = integration_matchers
self._address_data = {}
self._stop_event = Event()
def stop(self):
"""Stop the thread."""
self._stop_event.set()
def run(self):
"""Start watching for dhcp packets."""
try:
sniff(
filter=FILTER,
prn=self.handle_dhcp_packet,
stop_filter=lambda _: self._stop_event.is_set(),
)
except (Scapy_Exception, OSError) as ex:
_LOGGER.info("Cannot watch for dhcp packets: %s", ex)
return
def handle_dhcp_packet(self, packet):
"""Process a dhcp packet."""
if DHCP not in packet:
return
options = packet[DHCP].options
request_type = _decode_dhcp_option(options, MESSAGE_TYPE)
if request_type != DHCP_REQUEST:
# DHCP request
return
ip_address = _decode_dhcp_option(options, REQUESTED_ADDR)
hostname = _decode_dhcp_option(options, HOSTNAME)
mac_address = _format_mac(packet[Ether].src)
if ip_address is None or hostname is None or mac_address is None:
return
data = self._address_data.get(ip_address)
if data and data[MAC_ADDRESS] == mac_address and data[HOSTNAME] == hostname:
# If the address data is the same no need
# to process it
return
self._address_data[ip_address] = {MAC_ADDRESS: mac_address, HOSTNAME: hostname}
self.process_updated_address_data(ip_address, self._address_data[ip_address])
def process_updated_address_data(self, ip_address, data):
"""Process the address data update."""
lowercase_hostname = data[HOSTNAME].lower()
uppercase_mac = data[MAC_ADDRESS].upper()
_LOGGER.debug(
"Processing updated address data for %s: mac=%s hostname=%s",
ip_address,
uppercase_mac,
lowercase_hostname,
)
for entry in self._integration_matchers:
if MAC_ADDRESS in entry and not fnmatch.fnmatch(
uppercase_mac, entry[MAC_ADDRESS]
):
continue
if HOSTNAME in entry and not fnmatch.fnmatch(
lowercase_hostname, entry[HOSTNAME]
):
continue
_LOGGER.debug("Matched %s against %s", data, entry)
self.hass.add_job(
self.hass.config_entries.flow.async_init(
entry["domain"],
context={"source": DOMAIN},
data={IP_ADDRESS: ip_address, **data},
)
)
def _decode_dhcp_option(dhcp_options, key):
"""Extract and decode data from a packet option."""
for option in dhcp_options:
if len(option) < 2 or option[0] != key:
continue
value = option[1]
if value is None or key != HOSTNAME:
return value
# hostname is unicode
try:
return value.decode()
except (AttributeError, UnicodeDecodeError):
return None
def _format_mac(mac_address):
"""Format a mac address for matching."""
return format_mac(mac_address).replace(":", "")

View file

@ -0,0 +1,3 @@
"""Constants for the dhcp integration."""
DOMAIN = "dhcp"

View file

@ -0,0 +1,11 @@
{
"domain": "dhcp",
"name": "DHCP Discovery",
"documentation": "https://www.home-assistant.io/integrations/dhcp",
"requirements": [
"scapy==2.4.4"
],
"codeowners": [
"@bdraco"
]
}

View file

@ -4,5 +4,9 @@
"documentation": "https://www.home-assistant.io/integrations/flume/",
"requirements": ["pyflume==0.5.5"],
"codeowners": ["@ChrisMandich", "@bdraco"],
"config_flow": true
"config_flow": true,
"dhcp": [
{"hostname":"flume-gw-*","macaddress":"ECFABC*"},
{"hostname":"flume-gw-*","macaddress":"B4E62D*"}
]
}

View file

@ -6,5 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/nest",
"requirements": ["python-nest==4.1.0", "google-nest-sdm==0.2.8"],
"codeowners": ["@allenporter"],
"quality_scale": "platinum"
"quality_scale": "platinum",
"dhcp": [{"macaddress":"18B430*"}]
}

View file

@ -4,5 +4,6 @@
"requirements": ["nexia==0.9.5"],
"codeowners": ["@bdraco"],
"documentation": "https://www.home-assistant.io/integrations/nexia",
"config_flow": true
"config_flow": true,
"dhcp": [{"hostname":"xl857-*","macaddress":"000231*"}]
}

View file

@ -4,5 +4,6 @@
"documentation": "https://www.home-assistant.io/integrations/nuheat",
"requirements": ["nuheat==0.3.0"],
"codeowners": ["@bdraco"],
"config_flow": true
"config_flow": true,
"dhcp": [{"hostname":"nuheat","macaddress":"002338*"}]
}

View file

@ -4,5 +4,9 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/powerwall",
"requirements": ["tesla-powerwall==0.3.3"],
"codeowners": ["@bdraco", "@jrester"]
"codeowners": ["@bdraco", "@jrester"],
"dhcp": [
{"hostname":"1118431-*","macaddress":"88DA1A*"},
{"hostname":"1118431-*","macaddress":"000145*"}
]
}

View file

@ -7,6 +7,18 @@
"after_dependencies": ["cloud"],
"codeowners": ["@bdraco"],
"config_flow": true,
"dhcp": [{
"hostname": "rachio-*",
"macaddress": "009D6B*"
},
{
"hostname": "rachio-*",
"macaddress": "F0038C*"
},
{
"hostname": "rachio-*",
"macaddress": "74C63B*"
}],
"homekit": {
"models": ["Rachio"]
}

View file

@ -5,5 +5,6 @@
"requirements": ["ring_doorbell==0.6.2"],
"dependencies": ["ffmpeg"],
"codeowners": ["@balloob"],
"config_flow": true
"config_flow": true,
"dhcp": [{"hostname":"ring*","macaddress":"0CAE7D*"}]
}

View file

@ -4,5 +4,6 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/roomba",
"requirements": ["roombapy==1.6.2"],
"codeowners": ["@pschmitt", "@cyr-ius", "@shenxn"]
"codeowners": ["@pschmitt", "@cyr-ius", "@shenxn"],
"dhcp": [{"hostname":"irobot-*","macaddress":"501479*"}]
}

View file

@ -4,5 +4,6 @@
"documentation": "https://www.home-assistant.io/integrations/sense",
"requirements": ["sense_energy==0.8.1"],
"codeowners": ["@kbickar"],
"config_flow": true
"config_flow": true,
"dhcp": [{"hostname":"sense-*","macaddress":"009D6B*"}, {"hostname":"sense-*","macaddress":"DCEFCA*"}]
}

View file

@ -4,5 +4,6 @@
"documentation": "https://www.home-assistant.io/integrations/solaredge",
"requirements": ["solaredge==0.0.2", "stringcase==1.2.0"],
"config_flow": true,
"codeowners": []
"codeowners": [],
"dhcp": [{"hostname":"target","macaddress":"002702*"}]
}

View file

@ -5,5 +5,8 @@
"documentation": "https://www.home-assistant.io/integrations/somfy",
"dependencies": ["http"],
"codeowners": ["@tetienne"],
"requirements": ["pymfy==0.9.3"]
}
"requirements": ["pymfy==0.9.3"],
"dhcp": [
{"hostname":"gateway-*","macaddress":"F8811A*"}
]
}

View file

@ -6,5 +6,8 @@
"somfy-mylink-synergy==1.0.6"
],
"codeowners": ["@bdraco"],
"config_flow": true
}
"config_flow": true,
"dhcp": [{
"hostname":"somfy_*", "macaddress":"B8B7F1*"
}]
}

View file

@ -4,5 +4,10 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/tesla",
"requirements": ["teslajsonpy==0.10.4"],
"codeowners": ["@zabuldon", "@alandtse"]
"codeowners": ["@zabuldon", "@alandtse"],
"dhcp": [
{"hostname":"tesla_*","macaddress":"4CFCAA*"},
{"hostname":"tesla_*","macaddress":"044EAF*"},
{"hostname":"tesla_*","macaddress":"98ED5C*"}
]
}

View file

@ -29,6 +29,7 @@ SOURCE_MQTT = "mqtt"
SOURCE_SSDP = "ssdp"
SOURCE_USER = "user"
SOURCE_ZEROCONF = "zeroconf"
SOURCE_DHCP = "dhcp"
# If a user wants to hide a discovery from the UI they can "Ignore" it. The config_entries/ignore_flow
# websocket command creates a config entry with this source and while it exists normal discoveries
@ -1045,6 +1046,7 @@ class ConfigFlow(data_entry_flow.FlowHandler):
async_step_mqtt = async_step_discovery
async_step_ssdp = async_step_discovery
async_step_zeroconf = async_step_discovery
async_step_dhcp = async_step_discovery
class OptionsFlowManager(data_entry_flow.FlowManager):

View file

@ -0,0 +1,118 @@
"""Automatically generated by hassfest.
To update, run python3 -m script.hassfest
"""
# fmt: off
DHCP = [
{
"domain": "august",
"hostname": "connect",
"macaddress": "D86162*"
},
{
"domain": "august",
"hostname": "connect",
"macaddress": "B8B7F1*"
},
{
"domain": "flume",
"hostname": "flume-gw-*",
"macaddress": "ECFABC*"
},
{
"domain": "flume",
"hostname": "flume-gw-*",
"macaddress": "B4E62D*"
},
{
"domain": "nest",
"macaddress": "18B430*"
},
{
"domain": "nexia",
"hostname": "xl857-*",
"macaddress": "000231*"
},
{
"domain": "nuheat",
"hostname": "nuheat",
"macaddress": "002338*"
},
{
"domain": "powerwall",
"hostname": "1118431-*",
"macaddress": "88DA1A*"
},
{
"domain": "powerwall",
"hostname": "1118431-*",
"macaddress": "000145*"
},
{
"domain": "rachio",
"hostname": "rachio-*",
"macaddress": "009D6B*"
},
{
"domain": "rachio",
"hostname": "rachio-*",
"macaddress": "F0038C*"
},
{
"domain": "rachio",
"hostname": "rachio-*",
"macaddress": "74C63B*"
},
{
"domain": "ring",
"hostname": "ring*",
"macaddress": "0CAE7D*"
},
{
"domain": "roomba",
"hostname": "irobot-*",
"macaddress": "501479*"
},
{
"domain": "sense",
"hostname": "sense-*",
"macaddress": "009D6B*"
},
{
"domain": "sense",
"hostname": "sense-*",
"macaddress": "DCEFCA*"
},
{
"domain": "solaredge",
"hostname": "target",
"macaddress": "002702*"
},
{
"domain": "somfy",
"hostname": "gateway-*",
"macaddress": "F8811A*"
},
{
"domain": "somfy_mylink",
"hostname": "somfy_*",
"macaddress": "B8B7F1*"
},
{
"domain": "tesla",
"hostname": "tesla_*",
"macaddress": "4CFCAA*"
},
{
"domain": "tesla",
"hostname": "tesla_*",
"macaddress": "044EAF*"
},
{
"domain": "tesla",
"hostname": "tesla_*",
"macaddress": "98ED5C*"
}
]

View file

@ -82,6 +82,7 @@ class DiscoveryFlowHandler(config_entries.ConfigFlow):
async_step_ssdp = async_step_discovery
async_step_mqtt = async_step_discovery
async_step_homekit = async_step_discovery
async_step_dhcp = async_step_discovery
async def async_step_import(self, _: Optional[Dict[str, Any]]) -> Dict[str, Any]:
"""Handle a flow initialized by import."""

View file

@ -329,6 +329,7 @@ class AbstractOAuth2FlowHandler(config_entries.ConfigFlow, metaclass=ABCMeta):
async_step_ssdp = async_step_discovery
async_step_zeroconf = async_step_discovery
async_step_homekit = async_step_discovery
async_step_dhcp = async_step_discovery
@classmethod
def async_register_implementation(

View file

@ -25,6 +25,7 @@ from typing import (
cast,
)
from homeassistant.generated.dhcp import DHCP
from homeassistant.generated.mqtt import MQTT
from homeassistant.generated.ssdp import SSDP
from homeassistant.generated.zeroconf import HOMEKIT, ZEROCONF
@ -171,6 +172,20 @@ async def async_get_zeroconf(hass: "HomeAssistant") -> Dict[str, List[Dict[str,
return zeroconf
async def async_get_dhcp(hass: "HomeAssistant") -> List[Dict[str, str]]:
"""Return cached list of dhcp types."""
dhcp: List[Dict[str, str]] = DHCP.copy()
integrations = await async_get_custom_components(hass)
for integration in integrations.values():
if not integration.dhcp:
continue
for entry in integration.dhcp:
dhcp.append({"domain": integration.domain, **entry})
return dhcp
async def async_get_homekit(hass: "HomeAssistant") -> Dict[str, str]:
"""Return cached list of homekit models."""
@ -356,6 +371,11 @@ class Integration:
"""Return Integration zeroconf entries."""
return cast(List[str], self.manifest.get("zeroconf"))
@property
def dhcp(self) -> Optional[list]:
"""Return Integration dhcp entries."""
return cast(List[str], self.manifest.get("dhcp"))
@property
def homekit(self) -> Optional[dict]:
"""Return Integration homekit entries."""

View file

@ -25,6 +25,7 @@ pytz>=2020.5
pyyaml==5.3.1
requests==2.25.1
ruamel.yaml==0.15.100
scapy==2.4.4
sqlalchemy==1.3.22
voluptuous-serialize==2.4.0
voluptuous==0.12.1

View file

@ -14,6 +14,7 @@ DATA_PKG_CACHE = "pkg_cache"
DATA_INTEGRATIONS_WITH_REQS = "integrations_with_reqs"
CONSTRAINT_FILE = "package_constraints.txt"
DISCOVERY_INTEGRATIONS: Dict[str, Iterable[str]] = {
"dhcp": ("dhcp",),
"mqtt": ("mqtt",),
"ssdp": ("ssdp",),
"zeroconf": ("zeroconf", "homekit"),

View file

@ -1984,6 +1984,9 @@ samsungtvws==1.4.0
# homeassistant.components.satel_integra
satel_integra==0.3.4
# homeassistant.components.dhcp
scapy==2.4.4
# homeassistant.components.deutsche_bahn
schiene==0.23

View file

@ -980,6 +980,9 @@ samsungctl[websocket]==0.7.1
# homeassistant.components.samsungtv
samsungtvws==1.4.0
# homeassistant.components.dhcp
scapy==2.4.4
# homeassistant.components.emulated_kasa
# homeassistant.components.sense
sense_energy==0.8.1

View file

@ -9,6 +9,7 @@ from . import (
config_flow,
coverage,
dependencies,
dhcp,
json,
manifest,
mqtt,
@ -31,6 +32,7 @@ INTEGRATION_PLUGINS = [
ssdp,
translations,
zeroconf,
dhcp,
]
HASS_PLUGINS = [
coverage,

View file

@ -48,6 +48,11 @@ def validate_integration(config: Config, integration: Integration):
"config_flow",
"Zeroconf information in a manifest requires a config flow to exist",
)
if integration.manifest.get("dhcp"):
integration.add_error(
"config_flow",
"DHCP information in a manifest requires a config flow to exist",
)
return
config_flow = config_flow_file.read_text()
@ -59,6 +64,7 @@ def validate_integration(config: Config, integration: Integration):
or "async_step_mqtt" in config_flow
or "async_step_ssdp" in config_flow
or "async_step_zeroconf" in config_flow
or "async_step_dhcp" in config_flow
)
if not needs_unique_id:
@ -100,6 +106,7 @@ def generate_and_validate(integrations: Dict[str, Integration], config: Config):
or integration.manifest.get("mqtt")
or integration.manifest.get("ssdp")
or integration.manifest.get("zeroconf")
or integration.manifest.get("dhcp")
):
continue

63
script/hassfest/dhcp.py Normal file
View file

@ -0,0 +1,63 @@
"""Generate dhcp file."""
import json
from typing import Dict, List
from .model import Config, Integration
BASE = """
\"\"\"Automatically generated by hassfest.
To update, run python3 -m script.hassfest
\"\"\"
# fmt: off
DHCP = {}
""".strip()
def generate_and_validate(integrations: List[Dict[str, str]]):
"""Validate and generate dhcp data."""
match_list = []
for domain in sorted(integrations):
integration = integrations[domain]
if not integration.manifest:
continue
match_types = integration.manifest.get("dhcp", [])
if not match_types:
continue
for entry in match_types:
match_list.append({"domain": domain, **entry})
return BASE.format(json.dumps(match_list, indent=4))
def validate(integrations: Dict[str, Integration], config: Config):
"""Validate dhcp file."""
dhcp_path = config.root / "homeassistant/generated/dhcp.py"
config.cache["dhcp"] = content = generate_and_validate(integrations)
if config.specific_integrations:
return
with open(str(dhcp_path)) as fp:
current = fp.read().strip()
if current != content:
config.add_error(
"dhcp",
"File dhcp.py is not up to date. Run python3 -m script.hassfest",
fixable=True,
)
return
def generate(integrations: Dict[str, Integration], config: Config):
"""Generate dhcp file."""
dhcp_path = config.root / "homeassistant/generated/dhcp.py"
with open(str(dhcp_path), "w") as fp:
fp.write(f"{config.cache['dhcp']}\n")

View file

@ -71,6 +71,14 @@ MANIFEST_SCHEMA = vol.Schema(
vol.All([vol.All(vol.Schema({}, extra=vol.ALLOW_EXTRA), vol.Length(min=1))])
),
vol.Optional("homekit"): vol.Schema({vol.Optional("models"): [str]}),
vol.Optional("dhcp"): [
vol.Schema(
{
vol.Optional("macaddress"): vol.All(str, verify_uppercase),
vol.Optional("hostname"): vol.All(str, verify_lowercase),
}
)
],
vol.Required("documentation"): vol.All(
vol.Url(), documentation_url # pylint: disable=no-value-for-parameter
),

View file

@ -0,0 +1 @@
"""Tests for the dhcp integration."""

View file

@ -0,0 +1,302 @@
"""Test the DHCP discovery integration."""
import threading
from unittest.mock import patch
from scapy.error import Scapy_Exception
from scapy.layers.dhcp import DHCP
from scapy.layers.l2 import Ether
from homeassistant.components import dhcp
from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP
from homeassistant.setup import async_setup_component
from tests.common import mock_coro
# connect b8:b7:f1:6d:b5:33 192.168.210.56
RAW_DHCP_REQUEST = (
b"\xff\xff\xff\xff\xff\xff\xb8\xb7\xf1m\xb53\x08\x00E\x00\x01P\x06E"
b"\x00\x00\xff\x11\xb4X\x00\x00\x00\x00\xff\xff\xff\xff\x00D\x00C\x01<"
b"\x0b\x14\x01\x01\x06\x00jmjV\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"
b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xb8\xb7\xf1m\xb53\x00\x00\x00\x00"
b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"
b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"
b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"
b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"
b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"
b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"
b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"
b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"
b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"
b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"
b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"
b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"
b"\x00\x00\x00\x00\x00\x00c\x82Sc5\x01\x039\x02\x05\xdc2\x04\xc0\xa8\xd286"
b"\x04\xc0\xa8\xd0\x017\x04\x01\x03\x1c\x06\x0c\x07connect\xff\x00\x00\x00"
b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"
b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"
)
async def test_dhcp_match_hostname_and_macaddress(hass):
"""Test matching based on hostname and macaddress."""
dhcp_watcher = dhcp.DHCPWatcher(
hass,
[{"domain": "mock-domain", "hostname": "connect", "macaddress": "B8B7F1*"}],
)
packet = Ether(RAW_DHCP_REQUEST)
with patch.object(
hass.config_entries.flow, "async_init", return_value=mock_coro()
) as mock_init:
dhcp_watcher.handle_dhcp_packet(packet)
# Ensure no change is ignored
dhcp_watcher.handle_dhcp_packet(packet)
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_dhcp_match_hostname(hass):
"""Test matching based on hostname only."""
dhcp_watcher = dhcp.DHCPWatcher(
hass, [{"domain": "mock-domain", "hostname": "connect"}]
)
packet = Ether(RAW_DHCP_REQUEST)
with patch.object(
hass.config_entries.flow, "async_init", return_value=mock_coro()
) as mock_init:
dhcp_watcher.handle_dhcp_packet(packet)
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_dhcp_match_macaddress(hass):
"""Test matching based on macaddress only."""
dhcp_watcher = dhcp.DHCPWatcher(
hass, [{"domain": "mock-domain", "macaddress": "B8B7F1*"}]
)
packet = Ether(RAW_DHCP_REQUEST)
with patch.object(
hass.config_entries.flow, "async_init", return_value=mock_coro()
) as mock_init:
dhcp_watcher.handle_dhcp_packet(packet)
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_dhcp_nomatch(hass):
"""Test not matching based on macaddress only."""
dhcp_watcher = dhcp.DHCPWatcher(
hass, [{"domain": "mock-domain", "macaddress": "ABC123*"}]
)
packet = Ether(RAW_DHCP_REQUEST)
with patch.object(
hass.config_entries.flow, "async_init", return_value=mock_coro()
) as mock_init:
dhcp_watcher.handle_dhcp_packet(packet)
assert len(mock_init.mock_calls) == 0
async def test_dhcp_nomatch_hostname(hass):
"""Test not matching based on hostname only."""
dhcp_watcher = dhcp.DHCPWatcher(
hass, [{"domain": "mock-domain", "hostname": "nomatch*"}]
)
packet = Ether(RAW_DHCP_REQUEST)
with patch.object(
hass.config_entries.flow, "async_init", return_value=mock_coro()
) as mock_init:
dhcp_watcher.handle_dhcp_packet(packet)
assert len(mock_init.mock_calls) == 0
async def test_dhcp_nomatch_non_dhcp_packet(hass):
"""Test matching does not throw on a non-dhcp packet."""
dhcp_watcher = dhcp.DHCPWatcher(
hass, [{"domain": "mock-domain", "hostname": "nomatch*"}]
)
packet = Ether(b"")
with patch.object(
hass.config_entries.flow, "async_init", return_value=mock_coro()
) as mock_init:
dhcp_watcher.handle_dhcp_packet(packet)
assert len(mock_init.mock_calls) == 0
async def test_dhcp_nomatch_non_dhcp_request_packet(hass):
"""Test nothing happens with the wrong message-type."""
dhcp_watcher = dhcp.DHCPWatcher(
hass, [{"domain": "mock-domain", "hostname": "nomatch*"}]
)
packet = Ether(RAW_DHCP_REQUEST)
packet[DHCP].options = [
("message-type", 4),
("max_dhcp_size", 1500),
("requested_addr", "192.168.210.56"),
("server_id", "192.168.208.1"),
("param_req_list", [1, 3, 28, 6]),
("hostname", b"connect"),
]
with patch.object(
hass.config_entries.flow, "async_init", return_value=mock_coro()
) as mock_init:
dhcp_watcher.handle_dhcp_packet(packet)
assert len(mock_init.mock_calls) == 0
async def test_dhcp_invalid_hostname(hass):
"""Test we ignore invalid hostnames."""
dhcp_watcher = dhcp.DHCPWatcher(
hass, [{"domain": "mock-domain", "hostname": "nomatch*"}]
)
packet = Ether(RAW_DHCP_REQUEST)
packet[DHCP].options = [
("message-type", 3),
("max_dhcp_size", 1500),
("requested_addr", "192.168.210.56"),
("server_id", "192.168.208.1"),
("param_req_list", [1, 3, 28, 6]),
("hostname", "connect"),
]
with patch.object(
hass.config_entries.flow, "async_init", return_value=mock_coro()
) as mock_init:
dhcp_watcher.handle_dhcp_packet(packet)
assert len(mock_init.mock_calls) == 0
async def test_dhcp_missing_hostname(hass):
"""Test we ignore missing hostnames."""
dhcp_watcher = dhcp.DHCPWatcher(
hass, [{"domain": "mock-domain", "hostname": "nomatch*"}]
)
packet = Ether(RAW_DHCP_REQUEST)
packet[DHCP].options = [
("message-type", 3),
("max_dhcp_size", 1500),
("requested_addr", "192.168.210.56"),
("server_id", "192.168.208.1"),
("param_req_list", [1, 3, 28, 6]),
("hostname", None),
]
with patch.object(
hass.config_entries.flow, "async_init", return_value=mock_coro()
) as mock_init:
dhcp_watcher.handle_dhcp_packet(packet)
assert len(mock_init.mock_calls) == 0
async def test_dhcp_invalid_option(hass):
"""Test we ignore invalid hostname option."""
dhcp_watcher = dhcp.DHCPWatcher(
hass, [{"domain": "mock-domain", "hostname": "nomatch*"}]
)
packet = Ether(RAW_DHCP_REQUEST)
packet[DHCP].options = [
("message-type", 3),
("max_dhcp_size", 1500),
("requested_addr", "192.168.208.55"),
("server_id", "192.168.208.1"),
("param_req_list", [1, 3, 28, 6]),
("hostname"),
]
with patch.object(
hass.config_entries.flow, "async_init", return_value=mock_coro()
) as mock_init:
dhcp_watcher.handle_dhcp_packet(packet)
assert len(mock_init.mock_calls) == 0
async def test_setup_and_stop(hass):
"""Test we can setup and stop."""
assert await async_setup_component(
hass,
dhcp.DOMAIN,
{},
)
await hass.async_block_till_done()
wait_event = threading.Event()
def _sniff_wait():
wait_event.wait()
with patch("homeassistant.components.dhcp.sniff", _sniff_wait):
hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED)
await hass.async_block_till_done()
hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP)
await hass.async_block_till_done()
wait_event.set()
async def test_setup_fails(hass):
"""Test we handle sniff setup failing."""
assert await async_setup_component(
hass,
dhcp.DOMAIN,
{},
)
await hass.async_block_till_done()
wait_event = threading.Event()
with patch("homeassistant.components.dhcp.sniff", side_effect=Scapy_Exception):
hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED)
await hass.async_block_till_done()
hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP)
await hass.async_block_till_done()
wait_event.set()

View file

@ -82,7 +82,7 @@ async def test_user_has_confirmation(hass, discovery_flow_conf):
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
@pytest.mark.parametrize("source", ["discovery", "mqtt", "ssdp", "zeroconf"])
@pytest.mark.parametrize("source", ["discovery", "mqtt", "ssdp", "zeroconf", "dhcp"])
async def test_discovery_single_instance(hass, discovery_flow_conf, source):
"""Test we not allow duplicates."""
flow = config_entries.HANDLERS["test"]()
@ -96,7 +96,7 @@ async def test_discovery_single_instance(hass, discovery_flow_conf, source):
assert result["reason"] == "single_instance_allowed"
@pytest.mark.parametrize("source", ["discovery", "mqtt", "ssdp", "zeroconf"])
@pytest.mark.parametrize("source", ["discovery", "mqtt", "ssdp", "zeroconf", "dhcp"])
async def test_discovery_confirmation(hass, discovery_flow_conf, source):
"""Test we ask for confirmation via discovery."""
flow = config_entries.HANDLERS["test"]()

View file

@ -172,6 +172,11 @@ def test_integration_properties(hass):
"requirements": ["test-req==1.0.0"],
"zeroconf": ["_hue._tcp.local."],
"homekit": {"models": ["BSB002"]},
"dhcp": [
{"hostname": "tesla_*", "macaddress": "4CFCAA*"},
{"hostname": "tesla_*", "macaddress": "044EAF*"},
{"hostname": "tesla_*", "macaddress": "98ED5C*"},
],
"ssdp": [
{
"manufacturer": "Royal Philips Electronics",
@ -190,6 +195,11 @@ def test_integration_properties(hass):
assert integration.domain == "hue"
assert integration.homekit == {"models": ["BSB002"]}
assert integration.zeroconf == ["_hue._tcp.local."]
assert integration.dhcp == [
{"hostname": "tesla_*", "macaddress": "4CFCAA*"},
{"hostname": "tesla_*", "macaddress": "044EAF*"},
{"hostname": "tesla_*", "macaddress": "98ED5C*"},
]
assert integration.ssdp == [
{
"manufacturer": "Royal Philips Electronics",
@ -220,6 +230,7 @@ def test_integration_properties(hass):
assert integration.is_built_in is False
assert integration.homekit is None
assert integration.zeroconf is None
assert integration.dhcp is None
assert integration.ssdp is None
assert integration.mqtt is None
@ -238,6 +249,7 @@ def test_integration_properties(hass):
assert integration.is_built_in is False
assert integration.homekit is None
assert integration.zeroconf == [{"type": "_hue._tcp.local.", "name": "hue*"}]
assert integration.dhcp is None
assert integration.ssdp is None
@ -295,6 +307,30 @@ def _get_test_integration_with_zeroconf_matcher(hass, name, config_flow):
)
def _get_test_integration_with_dhcp_matcher(hass, name, config_flow):
"""Return a generated test integration with a dhcp matcher."""
return loader.Integration(
hass,
f"homeassistant.components.{name}",
None,
{
"name": name,
"domain": name,
"config_flow": config_flow,
"dependencies": [],
"requirements": [],
"zeroconf": [],
"dhcp": [
{"hostname": "tesla_*", "macaddress": "4CFCAA*"},
{"hostname": "tesla_*", "macaddress": "044EAF*"},
{"hostname": "tesla_*", "macaddress": "98ED5C*"},
],
"homekit": {"models": [name]},
"ssdp": [{"manufacturer": name, "modelName": name}],
},
)
async def test_get_custom_components(hass, enable_custom_integrations):
"""Verify that custom components are cached."""
test_1_integration = _get_test_integration(hass, "test_1", False)
@ -347,6 +383,23 @@ async def test_get_zeroconf(hass):
]
async def test_get_dhcp(hass):
"""Verify that custom components with dhcp are found."""
test_1_integration = _get_test_integration_with_dhcp_matcher(hass, "test_1", True)
with patch("homeassistant.loader.async_get_custom_components") as mock_get:
mock_get.return_value = {
"test_1": test_1_integration,
}
dhcp = await loader.async_get_dhcp(hass)
dhcp_for_domain = [entry for entry in dhcp if entry["domain"] == "test_1"]
assert dhcp_for_domain == [
{"domain": "test_1", "hostname": "tesla_*", "macaddress": "4CFCAA*"},
{"domain": "test_1", "hostname": "tesla_*", "macaddress": "044EAF*"},
{"domain": "test_1", "hostname": "tesla_*", "macaddress": "98ED5C*"},
]
async def test_get_homekit(hass):
"""Verify that custom components with homekit are found."""
test_1_integration = _get_test_integration(hass, "test_1", True)

View file

@ -244,3 +244,26 @@ async def test_discovery_requirements_zeroconf(hass, partial_manifest):
assert len(mock_process.mock_calls) == 2 # zeroconf also depends on http
assert mock_process.mock_calls[0][1][2] == zeroconf.requirements
async def test_discovery_requirements_dhcp(hass):
"""Test that we load dhcp discovery requirements."""
hass.config.skip_pip = False
dhcp = await loader.async_get_integration(hass, "dhcp")
mock_integration(
hass,
MockModule(
"comp",
partial_manifest={
"dhcp": [{"hostname": "somfy_*", "macaddress": "B8B7F1*"}]
},
),
)
with patch(
"homeassistant.requirements.async_process_requirements",
) as mock_process:
await async_get_integration_with_requirements(hass, "comp")
assert len(mock_process.mock_calls) == 1 # dhcp does not depend on http
assert mock_process.mock_calls[0][1][2] == dhcp.requirements