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: hooks:
- id: codespell - id: codespell
args: 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" - --skip="./.*,*.csv,*.json"
- --quiet-level=2 - --quiet-level=2
exclude_types: [csv, json] exclude_types: [csv, json]

View file

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

View file

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

View file

@ -6,6 +6,7 @@
"automation", "automation",
"cloud", "cloud",
"counter", "counter",
"dhcp",
"frontend", "frontend",
"history", "history",
"input_boolean", "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/", "documentation": "https://www.home-assistant.io/integrations/flume/",
"requirements": ["pyflume==0.5.5"], "requirements": ["pyflume==0.5.5"],
"codeowners": ["@ChrisMandich", "@bdraco"], "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", "documentation": "https://www.home-assistant.io/integrations/nest",
"requirements": ["python-nest==4.1.0", "google-nest-sdm==0.2.8"], "requirements": ["python-nest==4.1.0", "google-nest-sdm==0.2.8"],
"codeowners": ["@allenporter"], "codeowners": ["@allenporter"],
"quality_scale": "platinum" "quality_scale": "platinum",
"dhcp": [{"macaddress":"18B430*"}]
} }

View file

@ -4,5 +4,6 @@
"requirements": ["nexia==0.9.5"], "requirements": ["nexia==0.9.5"],
"codeowners": ["@bdraco"], "codeowners": ["@bdraco"],
"documentation": "https://www.home-assistant.io/integrations/nexia", "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", "documentation": "https://www.home-assistant.io/integrations/nuheat",
"requirements": ["nuheat==0.3.0"], "requirements": ["nuheat==0.3.0"],
"codeowners": ["@bdraco"], "codeowners": ["@bdraco"],
"config_flow": true "config_flow": true,
"dhcp": [{"hostname":"nuheat","macaddress":"002338*"}]
} }

View file

@ -4,5 +4,9 @@
"config_flow": true, "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/powerwall", "documentation": "https://www.home-assistant.io/integrations/powerwall",
"requirements": ["tesla-powerwall==0.3.3"], "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"], "after_dependencies": ["cloud"],
"codeowners": ["@bdraco"], "codeowners": ["@bdraco"],
"config_flow": true, "config_flow": true,
"dhcp": [{
"hostname": "rachio-*",
"macaddress": "009D6B*"
},
{
"hostname": "rachio-*",
"macaddress": "F0038C*"
},
{
"hostname": "rachio-*",
"macaddress": "74C63B*"
}],
"homekit": { "homekit": {
"models": ["Rachio"] "models": ["Rachio"]
} }

View file

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

View file

@ -4,5 +4,6 @@
"config_flow": true, "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/roomba", "documentation": "https://www.home-assistant.io/integrations/roomba",
"requirements": ["roombapy==1.6.2"], "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", "documentation": "https://www.home-assistant.io/integrations/sense",
"requirements": ["sense_energy==0.8.1"], "requirements": ["sense_energy==0.8.1"],
"codeowners": ["@kbickar"], "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", "documentation": "https://www.home-assistant.io/integrations/solaredge",
"requirements": ["solaredge==0.0.2", "stringcase==1.2.0"], "requirements": ["solaredge==0.0.2", "stringcase==1.2.0"],
"config_flow": true, "config_flow": true,
"codeowners": [] "codeowners": [],
"dhcp": [{"hostname":"target","macaddress":"002702*"}]
} }

View file

@ -5,5 +5,8 @@
"documentation": "https://www.home-assistant.io/integrations/somfy", "documentation": "https://www.home-assistant.io/integrations/somfy",
"dependencies": ["http"], "dependencies": ["http"],
"codeowners": ["@tetienne"], "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" "somfy-mylink-synergy==1.0.6"
], ],
"codeowners": ["@bdraco"], "codeowners": ["@bdraco"],
"config_flow": true "config_flow": true,
"dhcp": [{
"hostname":"somfy_*", "macaddress":"B8B7F1*"
}]
} }

View file

@ -4,5 +4,10 @@
"config_flow": true, "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/tesla", "documentation": "https://www.home-assistant.io/integrations/tesla",
"requirements": ["teslajsonpy==0.10.4"], "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_SSDP = "ssdp"
SOURCE_USER = "user" SOURCE_USER = "user"
SOURCE_ZEROCONF = "zeroconf" 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 # 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 # 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_mqtt = async_step_discovery
async_step_ssdp = async_step_discovery async_step_ssdp = async_step_discovery
async_step_zeroconf = async_step_discovery async_step_zeroconf = async_step_discovery
async_step_dhcp = async_step_discovery
class OptionsFlowManager(data_entry_flow.FlowManager): 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_ssdp = async_step_discovery
async_step_mqtt = async_step_discovery async_step_mqtt = async_step_discovery
async_step_homekit = 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]: async def async_step_import(self, _: Optional[Dict[str, Any]]) -> Dict[str, Any]:
"""Handle a flow initialized by import.""" """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_ssdp = async_step_discovery
async_step_zeroconf = async_step_discovery async_step_zeroconf = async_step_discovery
async_step_homekit = async_step_discovery async_step_homekit = async_step_discovery
async_step_dhcp = async_step_discovery
@classmethod @classmethod
def async_register_implementation( def async_register_implementation(

View file

@ -25,6 +25,7 @@ from typing import (
cast, cast,
) )
from homeassistant.generated.dhcp import DHCP
from homeassistant.generated.mqtt import MQTT from homeassistant.generated.mqtt import MQTT
from homeassistant.generated.ssdp import SSDP from homeassistant.generated.ssdp import SSDP
from homeassistant.generated.zeroconf import HOMEKIT, ZEROCONF 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 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]: async def async_get_homekit(hass: "HomeAssistant") -> Dict[str, str]:
"""Return cached list of homekit models.""" """Return cached list of homekit models."""
@ -356,6 +371,11 @@ class Integration:
"""Return Integration zeroconf entries.""" """Return Integration zeroconf entries."""
return cast(List[str], self.manifest.get("zeroconf")) 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 @property
def homekit(self) -> Optional[dict]: def homekit(self) -> Optional[dict]:
"""Return Integration homekit entries.""" """Return Integration homekit entries."""

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -48,6 +48,11 @@ def validate_integration(config: Config, integration: Integration):
"config_flow", "config_flow",
"Zeroconf information in a manifest requires a config flow to exist", "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 return
config_flow = config_flow_file.read_text() 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_mqtt" in config_flow
or "async_step_ssdp" in config_flow or "async_step_ssdp" in config_flow
or "async_step_zeroconf" in config_flow or "async_step_zeroconf" in config_flow
or "async_step_dhcp" in config_flow
) )
if not needs_unique_id: 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("mqtt")
or integration.manifest.get("ssdp") or integration.manifest.get("ssdp")
or integration.manifest.get("zeroconf") or integration.manifest.get("zeroconf")
or integration.manifest.get("dhcp")
): ):
continue 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.All([vol.All(vol.Schema({}, extra=vol.ALLOW_EXTRA), vol.Length(min=1))])
), ),
vol.Optional("homekit"): vol.Schema({vol.Optional("models"): [str]}), 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.Required("documentation"): vol.All(
vol.Url(), documentation_url # pylint: disable=no-value-for-parameter 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 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): async def test_discovery_single_instance(hass, discovery_flow_conf, source):
"""Test we not allow duplicates.""" """Test we not allow duplicates."""
flow = config_entries.HANDLERS["test"]() 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" 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): async def test_discovery_confirmation(hass, discovery_flow_conf, source):
"""Test we ask for confirmation via discovery.""" """Test we ask for confirmation via discovery."""
flow = config_entries.HANDLERS["test"]() flow = config_entries.HANDLERS["test"]()

View file

@ -172,6 +172,11 @@ def test_integration_properties(hass):
"requirements": ["test-req==1.0.0"], "requirements": ["test-req==1.0.0"],
"zeroconf": ["_hue._tcp.local."], "zeroconf": ["_hue._tcp.local."],
"homekit": {"models": ["BSB002"]}, "homekit": {"models": ["BSB002"]},
"dhcp": [
{"hostname": "tesla_*", "macaddress": "4CFCAA*"},
{"hostname": "tesla_*", "macaddress": "044EAF*"},
{"hostname": "tesla_*", "macaddress": "98ED5C*"},
],
"ssdp": [ "ssdp": [
{ {
"manufacturer": "Royal Philips Electronics", "manufacturer": "Royal Philips Electronics",
@ -190,6 +195,11 @@ def test_integration_properties(hass):
assert integration.domain == "hue" assert integration.domain == "hue"
assert integration.homekit == {"models": ["BSB002"]} assert integration.homekit == {"models": ["BSB002"]}
assert integration.zeroconf == ["_hue._tcp.local."] assert integration.zeroconf == ["_hue._tcp.local."]
assert integration.dhcp == [
{"hostname": "tesla_*", "macaddress": "4CFCAA*"},
{"hostname": "tesla_*", "macaddress": "044EAF*"},
{"hostname": "tesla_*", "macaddress": "98ED5C*"},
]
assert integration.ssdp == [ assert integration.ssdp == [
{ {
"manufacturer": "Royal Philips Electronics", "manufacturer": "Royal Philips Electronics",
@ -220,6 +230,7 @@ def test_integration_properties(hass):
assert integration.is_built_in is False assert integration.is_built_in is False
assert integration.homekit is None assert integration.homekit is None
assert integration.zeroconf is None assert integration.zeroconf is None
assert integration.dhcp is None
assert integration.ssdp is None assert integration.ssdp is None
assert integration.mqtt 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.is_built_in is False
assert integration.homekit is None assert integration.homekit is None
assert integration.zeroconf == [{"type": "_hue._tcp.local.", "name": "hue*"}] assert integration.zeroconf == [{"type": "_hue._tcp.local.", "name": "hue*"}]
assert integration.dhcp is None
assert integration.ssdp 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): async def test_get_custom_components(hass, enable_custom_integrations):
"""Verify that custom components are cached.""" """Verify that custom components are cached."""
test_1_integration = _get_test_integration(hass, "test_1", False) 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): async def test_get_homekit(hass):
"""Verify that custom components with homekit are found.""" """Verify that custom components with homekit are found."""
test_1_integration = _get_test_integration(hass, "test_1", True) 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 len(mock_process.mock_calls) == 2 # zeroconf also depends on http
assert mock_process.mock_calls[0][1][2] == zeroconf.requirements 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