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:
parent
402a0ea7da
commit
da677f7d5a
38 changed files with 843 additions and 17 deletions
|
@ -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]
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -6,6 +6,7 @@
|
|||
"automation",
|
||||
"cloud",
|
||||
"counter",
|
||||
"dhcp",
|
||||
"frontend",
|
||||
"history",
|
||||
"input_boolean",
|
||||
|
|
159
homeassistant/components/dhcp/__init__.py
Normal file
159
homeassistant/components/dhcp/__init__.py
Normal 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(":", "")
|
3
homeassistant/components/dhcp/const.py
Normal file
3
homeassistant/components/dhcp/const.py
Normal file
|
@ -0,0 +1,3 @@
|
|||
"""Constants for the dhcp integration."""
|
||||
|
||||
DOMAIN = "dhcp"
|
11
homeassistant/components/dhcp/manifest.json
Normal file
11
homeassistant/components/dhcp/manifest.json
Normal 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"
|
||||
]
|
||||
}
|
|
@ -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*"}
|
||||
]
|
||||
}
|
||||
|
|
|
@ -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*"}]
|
||||
}
|
||||
|
|
|
@ -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*"}]
|
||||
}
|
||||
|
|
|
@ -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*"}]
|
||||
}
|
||||
|
|
|
@ -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*"}
|
||||
]
|
||||
}
|
||||
|
|
|
@ -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"]
|
||||
}
|
||||
|
|
|
@ -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*"}]
|
||||
}
|
||||
|
|
|
@ -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*"}]
|
||||
}
|
||||
|
|
|
@ -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*"}]
|
||||
}
|
||||
|
|
|
@ -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*"}]
|
||||
}
|
||||
|
|
|
@ -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*"}
|
||||
]
|
||||
}
|
|
@ -6,5 +6,8 @@
|
|||
"somfy-mylink-synergy==1.0.6"
|
||||
],
|
||||
"codeowners": ["@bdraco"],
|
||||
"config_flow": true
|
||||
"config_flow": true,
|
||||
"dhcp": [{
|
||||
"hostname":"somfy_*", "macaddress":"B8B7F1*"
|
||||
}]
|
||||
}
|
|
@ -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*"}
|
||||
]
|
||||
}
|
||||
|
|
|
@ -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):
|
||||
|
|
118
homeassistant/generated/dhcp.py
Normal file
118
homeassistant/generated/dhcp.py
Normal 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*"
|
||||
}
|
||||
]
|
|
@ -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."""
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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."""
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"),
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
63
script/hassfest/dhcp.py
Normal 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")
|
|
@ -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
|
||||
),
|
||||
|
|
1
tests/components/dhcp/__init__.py
Normal file
1
tests/components/dhcp/__init__.py
Normal file
|
@ -0,0 +1 @@
|
|||
"""Tests for the dhcp integration."""
|
302
tests/components/dhcp/test_init.py
Normal file
302
tests/components/dhcp/test_init.py
Normal 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()
|
|
@ -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"]()
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Add table
Reference in a new issue