diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 00f2373f2db..8944a69d9ed 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -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] diff --git a/CODEOWNERS b/CODEOWNERS index 720320430b1..d128a6563ab 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -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 diff --git a/homeassistant/components/august/manifest.json b/homeassistant/components/august/manifest.json index 67649b7edba..dcdfb0a0497 100644 --- a/homeassistant/components/august/manifest.json +++ b/homeassistant/components/august/manifest.json @@ -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 } diff --git a/homeassistant/components/default_config/manifest.json b/homeassistant/components/default_config/manifest.json index 9a533092b8b..f8be3c9fe2a 100644 --- a/homeassistant/components/default_config/manifest.json +++ b/homeassistant/components/default_config/manifest.json @@ -6,6 +6,7 @@ "automation", "cloud", "counter", + "dhcp", "frontend", "history", "input_boolean", diff --git a/homeassistant/components/dhcp/__init__.py b/homeassistant/components/dhcp/__init__.py new file mode 100644 index 00000000000..b677325726c --- /dev/null +++ b/homeassistant/components/dhcp/__init__.py @@ -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(":", "") diff --git a/homeassistant/components/dhcp/const.py b/homeassistant/components/dhcp/const.py new file mode 100644 index 00000000000..c28a699c64c --- /dev/null +++ b/homeassistant/components/dhcp/const.py @@ -0,0 +1,3 @@ +"""Constants for the dhcp integration.""" + +DOMAIN = "dhcp" diff --git a/homeassistant/components/dhcp/manifest.json b/homeassistant/components/dhcp/manifest.json new file mode 100644 index 00000000000..eda229ebec7 --- /dev/null +++ b/homeassistant/components/dhcp/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "dhcp", + "name": "DHCP Discovery", + "documentation": "https://www.home-assistant.io/integrations/dhcp", + "requirements": [ + "scapy==2.4.4" + ], + "codeowners": [ + "@bdraco" + ] +} diff --git a/homeassistant/components/flume/manifest.json b/homeassistant/components/flume/manifest.json index f00bccb886a..813b8788ed5 100644 --- a/homeassistant/components/flume/manifest.json +++ b/homeassistant/components/flume/manifest.json @@ -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*"} + ] } diff --git a/homeassistant/components/nest/manifest.json b/homeassistant/components/nest/manifest.json index 42b790e5612..bde21868cb5 100644 --- a/homeassistant/components/nest/manifest.json +++ b/homeassistant/components/nest/manifest.json @@ -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*"}] } diff --git a/homeassistant/components/nexia/manifest.json b/homeassistant/components/nexia/manifest.json index a3446f6168c..cb3493ebc55 100644 --- a/homeassistant/components/nexia/manifest.json +++ b/homeassistant/components/nexia/manifest.json @@ -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*"}] } diff --git a/homeassistant/components/nuheat/manifest.json b/homeassistant/components/nuheat/manifest.json index d479f570d60..92527f50660 100644 --- a/homeassistant/components/nuheat/manifest.json +++ b/homeassistant/components/nuheat/manifest.json @@ -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*"}] } diff --git a/homeassistant/components/powerwall/manifest.json b/homeassistant/components/powerwall/manifest.json index be83f6825e7..6b7b147d3c5 100644 --- a/homeassistant/components/powerwall/manifest.json +++ b/homeassistant/components/powerwall/manifest.json @@ -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*"} + ] } diff --git a/homeassistant/components/rachio/manifest.json b/homeassistant/components/rachio/manifest.json index 224f59ea173..ba81b65b37f 100644 --- a/homeassistant/components/rachio/manifest.json +++ b/homeassistant/components/rachio/manifest.json @@ -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"] } diff --git a/homeassistant/components/ring/manifest.json b/homeassistant/components/ring/manifest.json index 550da4d38ec..38083830311 100644 --- a/homeassistant/components/ring/manifest.json +++ b/homeassistant/components/ring/manifest.json @@ -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*"}] } diff --git a/homeassistant/components/roomba/manifest.json b/homeassistant/components/roomba/manifest.json index f2e5c8035aa..5ceb44ff780 100644 --- a/homeassistant/components/roomba/manifest.json +++ b/homeassistant/components/roomba/manifest.json @@ -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*"}] } diff --git a/homeassistant/components/sense/manifest.json b/homeassistant/components/sense/manifest.json index f0b20f27a55..bd132f1f983 100644 --- a/homeassistant/components/sense/manifest.json +++ b/homeassistant/components/sense/manifest.json @@ -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*"}] } diff --git a/homeassistant/components/solaredge/manifest.json b/homeassistant/components/solaredge/manifest.json index 59b8cba7446..f0a620021ad 100644 --- a/homeassistant/components/solaredge/manifest.json +++ b/homeassistant/components/solaredge/manifest.json @@ -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*"}] } diff --git a/homeassistant/components/somfy/manifest.json b/homeassistant/components/somfy/manifest.json index ea84bf34586..7fb41992164 100644 --- a/homeassistant/components/somfy/manifest.json +++ b/homeassistant/components/somfy/manifest.json @@ -5,5 +5,8 @@ "documentation": "https://www.home-assistant.io/integrations/somfy", "dependencies": ["http"], "codeowners": ["@tetienne"], - "requirements": ["pymfy==0.9.3"] -} \ No newline at end of file + "requirements": ["pymfy==0.9.3"], + "dhcp": [ + {"hostname":"gateway-*","macaddress":"F8811A*"} + ] +} diff --git a/homeassistant/components/somfy_mylink/manifest.json b/homeassistant/components/somfy_mylink/manifest.json index e9b4601dee3..a7be33583d2 100644 --- a/homeassistant/components/somfy_mylink/manifest.json +++ b/homeassistant/components/somfy_mylink/manifest.json @@ -6,5 +6,8 @@ "somfy-mylink-synergy==1.0.6" ], "codeowners": ["@bdraco"], - "config_flow": true -} \ No newline at end of file + "config_flow": true, + "dhcp": [{ + "hostname":"somfy_*", "macaddress":"B8B7F1*" + }] +} diff --git a/homeassistant/components/tesla/manifest.json b/homeassistant/components/tesla/manifest.json index f1f4df6edd6..3679c0f74d1 100644 --- a/homeassistant/components/tesla/manifest.json +++ b/homeassistant/components/tesla/manifest.json @@ -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*"} + ] } diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index f87e76edec8..eca157b6403 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -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): diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py new file mode 100644 index 00000000000..f9319c7432a --- /dev/null +++ b/homeassistant/generated/dhcp.py @@ -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*" + } +] diff --git a/homeassistant/helpers/config_entry_flow.py b/homeassistant/helpers/config_entry_flow.py index 6b9df47c4d8..889981537c6 100644 --- a/homeassistant/helpers/config_entry_flow.py +++ b/homeassistant/helpers/config_entry_flow.py @@ -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.""" diff --git a/homeassistant/helpers/config_entry_oauth2_flow.py b/homeassistant/helpers/config_entry_oauth2_flow.py index c4d7de3839e..653d07a333e 100644 --- a/homeassistant/helpers/config_entry_oauth2_flow.py +++ b/homeassistant/helpers/config_entry_oauth2_flow.py @@ -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( diff --git a/homeassistant/loader.py b/homeassistant/loader.py index ba29ff4a8da..fc0355c0892 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -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.""" diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index a399080594a..cb637a6f539 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -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 diff --git a/homeassistant/requirements.py b/homeassistant/requirements.py index cebfd95591f..f26c8fa8dfc 100644 --- a/homeassistant/requirements.py +++ b/homeassistant/requirements.py @@ -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"), diff --git a/requirements_all.txt b/requirements_all.txt index 097ea7c50c7..c11b35312b1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -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 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7ddd32be363..10307a16b8c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -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 diff --git a/script/hassfest/__main__.py b/script/hassfest/__main__.py index 4b2e91524e2..6e88efa2177 100644 --- a/script/hassfest/__main__.py +++ b/script/hassfest/__main__.py @@ -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, diff --git a/script/hassfest/config_flow.py b/script/hassfest/config_flow.py index d3402c3dc9a..9ae0daee1b0 100644 --- a/script/hassfest/config_flow.py +++ b/script/hassfest/config_flow.py @@ -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 diff --git a/script/hassfest/dhcp.py b/script/hassfest/dhcp.py new file mode 100644 index 00000000000..fbf695a9f73 --- /dev/null +++ b/script/hassfest/dhcp.py @@ -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") diff --git a/script/hassfest/manifest.py b/script/hassfest/manifest.py index 7500483ec53..af483c3c5e7 100644 --- a/script/hassfest/manifest.py +++ b/script/hassfest/manifest.py @@ -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 ), diff --git a/tests/components/dhcp/__init__.py b/tests/components/dhcp/__init__.py new file mode 100644 index 00000000000..fc58a7de903 --- /dev/null +++ b/tests/components/dhcp/__init__.py @@ -0,0 +1 @@ +"""Tests for the dhcp integration.""" diff --git a/tests/components/dhcp/test_init.py b/tests/components/dhcp/test_init.py new file mode 100644 index 00000000000..d4d22a5f929 --- /dev/null +++ b/tests/components/dhcp/test_init.py @@ -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() diff --git a/tests/helpers/test_config_entry_flow.py b/tests/helpers/test_config_entry_flow.py index b5ba206f908..874fd5df29a 100644 --- a/tests/helpers/test_config_entry_flow.py +++ b/tests/helpers/test_config_entry_flow.py @@ -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"]() diff --git a/tests/test_loader.py b/tests/test_loader.py index 00c6e2b0c20..c1c27f56cb7 100644 --- a/tests/test_loader.py +++ b/tests/test_loader.py @@ -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) diff --git a/tests/test_requirements.py b/tests/test_requirements.py index bc206a136c2..5f74e504de8 100644 --- a/tests/test_requirements.py +++ b/tests/test_requirements.py @@ -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