mirror of
https://github.com/home-assistant/core.git
synced 2025-07-26 06:37:52 +00:00
Autodetect zeroconf interface selection when not set (#49529)
This commit is contained in:
parent
9003dbfdf3
commit
cb4558c088
@ -5,10 +5,12 @@ from contextlib import suppress
|
||||
import fnmatch
|
||||
from functools import partial
|
||||
import ipaddress
|
||||
from ipaddress import ip_address
|
||||
import logging
|
||||
import socket
|
||||
from typing import Any, TypedDict
|
||||
from typing import Any, Iterable, TypedDict, cast
|
||||
|
||||
from pyroute2 import IPRoute
|
||||
import voluptuous as vol
|
||||
from zeroconf import (
|
||||
Error as ZeroconfError,
|
||||
@ -32,6 +34,7 @@ import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers.network import NoURLAvailableError, get_url
|
||||
from homeassistant.helpers.singleton import singleton
|
||||
from homeassistant.loader import async_get_homekit, async_get_zeroconf
|
||||
from homeassistant.util.network import is_loopback
|
||||
|
||||
from .models import HaServiceBrowser, HaZeroconf
|
||||
from .usage import install_multiple_zeroconf_catcher
|
||||
@ -55,6 +58,8 @@ DEFAULT_IPV6 = True
|
||||
HOMEKIT_PAIRED_STATUS_FLAG = "sf"
|
||||
HOMEKIT_MODEL = "md"
|
||||
|
||||
MDNS_TARGET_IP = "224.0.0.251"
|
||||
|
||||
# Property key=value has a max length of 255
|
||||
# so we use 230 to leave space for key=
|
||||
MAX_PROPERTY_VALUE_LEN = 230
|
||||
@ -66,9 +71,7 @@ CONFIG_SCHEMA = vol.Schema(
|
||||
{
|
||||
DOMAIN: vol.Schema(
|
||||
{
|
||||
vol.Optional(
|
||||
CONF_DEFAULT_INTERFACE, default=DEFAULT_DEFAULT_INTERFACE
|
||||
): cv.boolean,
|
||||
vol.Optional(CONF_DEFAULT_INTERFACE): cv.boolean,
|
||||
vol.Optional(CONF_IPV6, default=DEFAULT_IPV6): cv.boolean,
|
||||
}
|
||||
)
|
||||
@ -110,11 +113,59 @@ async def _async_get_instance(hass: HomeAssistant, **zcargs: Any) -> HaZeroconf:
|
||||
return zeroconf
|
||||
|
||||
|
||||
def _get_ip_route(dst_ip: str) -> Any:
|
||||
"""Get ip next hop."""
|
||||
return IPRoute().route("get", dst=dst_ip)
|
||||
|
||||
|
||||
def _first_ip_nexthop_from_route(routes: Iterable) -> None | str:
|
||||
"""Find the first RTA_PREFSRC in the routes."""
|
||||
_LOGGER.debug("Routes: %s", routes)
|
||||
for route in routes:
|
||||
for key, value in route["attrs"]:
|
||||
if key == "RTA_PREFSRC":
|
||||
return cast(str, value)
|
||||
return None
|
||||
|
||||
|
||||
async def async_detect_interfaces_setting(hass: HomeAssistant) -> InterfaceChoice:
|
||||
"""Auto detect the interfaces setting when unset."""
|
||||
routes = []
|
||||
try:
|
||||
routes = await hass.async_add_executor_job(_get_ip_route, MDNS_TARGET_IP)
|
||||
except Exception as ex: # pylint: disable=broad-except
|
||||
_LOGGER.debug(
|
||||
"The system could not auto detect routing data on your operating system; Zeroconf will broadcast on all interfaces",
|
||||
exc_info=ex,
|
||||
)
|
||||
return InterfaceChoice.All
|
||||
|
||||
if not (first_ip := _first_ip_nexthop_from_route(routes)):
|
||||
_LOGGER.debug(
|
||||
"The system could not auto detect the nexthop for %s on your operating system; Zeroconf will broadcast on all interfaces",
|
||||
MDNS_TARGET_IP,
|
||||
)
|
||||
return InterfaceChoice.All
|
||||
|
||||
if is_loopback(ip_address(first_ip)):
|
||||
_LOGGER.debug(
|
||||
"The next hop for %s is %s; Zeroconf will broadcast on all interfaces",
|
||||
MDNS_TARGET_IP,
|
||||
first_ip,
|
||||
)
|
||||
return InterfaceChoice.All
|
||||
|
||||
return InterfaceChoice.Default
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: dict) -> bool:
|
||||
"""Set up Zeroconf and make Home Assistant discoverable."""
|
||||
zc_config = config.get(DOMAIN, {})
|
||||
zc_args: dict = {}
|
||||
if zc_config.get(CONF_DEFAULT_INTERFACE, DEFAULT_DEFAULT_INTERFACE):
|
||||
|
||||
if CONF_DEFAULT_INTERFACE not in zc_config:
|
||||
zc_args["interfaces"] = await async_detect_interfaces_setting(hass)
|
||||
elif zc_config[CONF_DEFAULT_INTERFACE]:
|
||||
zc_args["interfaces"] = InterfaceChoice.Default
|
||||
if not zc_config.get(CONF_IPV6, DEFAULT_IPV6):
|
||||
zc_args["ip_version"] = IPVersion.V4Only
|
||||
|
@ -2,7 +2,7 @@
|
||||
"domain": "zeroconf",
|
||||
"name": "Zero-configuration networking (zeroconf)",
|
||||
"documentation": "https://www.home-assistant.io/integrations/zeroconf",
|
||||
"requirements": ["zeroconf==0.29.0"],
|
||||
"requirements": ["zeroconf==0.29.0","pyroute2==0.5.18"],
|
||||
"dependencies": ["api"],
|
||||
"codeowners": ["@bdraco"],
|
||||
"quality_scale": "internal",
|
||||
|
@ -23,6 +23,7 @@ netdisco==2.8.2
|
||||
paho-mqtt==1.5.1
|
||||
pillow==8.1.2
|
||||
pip>=8.0.3,<20.3
|
||||
pyroute2==0.5.18
|
||||
python-slugify==4.0.1
|
||||
pytz>=2021.1
|
||||
pyyaml==5.4.1
|
||||
|
@ -1672,6 +1672,9 @@ pyrisco==0.3.1
|
||||
# homeassistant.components.rituals_perfume_genie
|
||||
pyrituals==0.0.2
|
||||
|
||||
# homeassistant.components.zeroconf
|
||||
pyroute2==0.5.18
|
||||
|
||||
# homeassistant.components.ruckus_unleashed
|
||||
pyruckus==0.12
|
||||
|
||||
|
@ -920,6 +920,9 @@ pyrisco==0.3.1
|
||||
# homeassistant.components.rituals_perfume_genie
|
||||
pyrituals==0.0.2
|
||||
|
||||
# homeassistant.components.zeroconf
|
||||
pyroute2==0.5.18
|
||||
|
||||
# homeassistant.components.ruckus_unleashed
|
||||
pyruckus==0.12
|
||||
|
||||
|
@ -31,6 +31,27 @@ PROPERTIES = {
|
||||
HOMEKIT_STATUS_UNPAIRED = b"1"
|
||||
HOMEKIT_STATUS_PAIRED = b"0"
|
||||
|
||||
_ROUTE_NO_LOOPBACK = (
|
||||
{
|
||||
"attrs": [
|
||||
("RTA_TABLE", 254),
|
||||
("RTA_DST", "224.0.0.251"),
|
||||
("RTA_OIF", 4),
|
||||
("RTA_PREFSRC", "192.168.1.5"),
|
||||
],
|
||||
},
|
||||
)
|
||||
_ROUTE_LOOPBACK = (
|
||||
{
|
||||
"attrs": [
|
||||
("RTA_TABLE", 254),
|
||||
("RTA_DST", "224.0.0.251"),
|
||||
("RTA_OIF", 4),
|
||||
("RTA_PREFSRC", "127.0.0.1"),
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def service_update_mock(zeroconf, services, handlers, *, limit_service=None):
|
||||
"""Call service update handler."""
|
||||
@ -611,3 +632,62 @@ async def test_removed_ignored(hass, mock_zeroconf):
|
||||
assert len(mock_zeroconf.get_service_info.mock_calls) == 2
|
||||
assert mock_zeroconf.get_service_info.mock_calls[0][1][0] == "_service.added"
|
||||
assert mock_zeroconf.get_service_info.mock_calls[1][1][0] == "_service.updated"
|
||||
|
||||
|
||||
async def test_async_detect_interfaces_setting_non_loopback_route(hass, mock_zeroconf):
|
||||
"""Test without default interface config and the route returns a non-loopback address."""
|
||||
with patch.object(hass.config_entries.flow, "async_init"), patch.object(
|
||||
zeroconf, "HaServiceBrowser", side_effect=service_update_mock
|
||||
), patch(
|
||||
"homeassistant.components.zeroconf.IPRoute.route",
|
||||
return_value=_ROUTE_NO_LOOPBACK,
|
||||
):
|
||||
mock_zeroconf.get_service_info.side_effect = get_service_info_mock
|
||||
assert await async_setup_component(hass, zeroconf.DOMAIN, {zeroconf.DOMAIN: {}})
|
||||
hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert mock_zeroconf.called_with(interface_choice=InterfaceChoice.Default)
|
||||
|
||||
|
||||
async def test_async_detect_interfaces_setting_loopback_route(hass, mock_zeroconf):
|
||||
"""Test without default interface config and the route returns a loopback address."""
|
||||
with patch.object(hass.config_entries.flow, "async_init"), patch.object(
|
||||
zeroconf, "HaServiceBrowser", side_effect=service_update_mock
|
||||
), patch(
|
||||
"homeassistant.components.zeroconf.IPRoute.route", return_value=_ROUTE_LOOPBACK
|
||||
):
|
||||
mock_zeroconf.get_service_info.side_effect = get_service_info_mock
|
||||
assert await async_setup_component(hass, zeroconf.DOMAIN, {zeroconf.DOMAIN: {}})
|
||||
hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert mock_zeroconf.called_with(interface_choice=InterfaceChoice.All)
|
||||
|
||||
|
||||
async def test_async_detect_interfaces_setting_empty_route(hass, mock_zeroconf):
|
||||
"""Test without default interface config and the route returns nothing."""
|
||||
with patch.object(hass.config_entries.flow, "async_init"), patch.object(
|
||||
zeroconf, "HaServiceBrowser", side_effect=service_update_mock
|
||||
), patch("homeassistant.components.zeroconf.IPRoute.route", return_value=[]):
|
||||
mock_zeroconf.get_service_info.side_effect = get_service_info_mock
|
||||
assert await async_setup_component(hass, zeroconf.DOMAIN, {zeroconf.DOMAIN: {}})
|
||||
hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert mock_zeroconf.called_with(interface_choice=InterfaceChoice.All)
|
||||
|
||||
|
||||
async def test_async_detect_interfaces_setting_exception(hass, mock_zeroconf):
|
||||
"""Test without default interface config and the route throws an exception."""
|
||||
with patch.object(hass.config_entries.flow, "async_init"), patch.object(
|
||||
zeroconf, "HaServiceBrowser", side_effect=service_update_mock
|
||||
), patch(
|
||||
"homeassistant.components.zeroconf.IPRoute.route", side_effect=AttributeError
|
||||
):
|
||||
mock_zeroconf.get_service_info.side_effect = get_service_info_mock
|
||||
assert await async_setup_component(hass, zeroconf.DOMAIN, {zeroconf.DOMAIN: {}})
|
||||
hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert mock_zeroconf.called_with(interface_choice=InterfaceChoice.All)
|
||||
|
Loading…
x
Reference in New Issue
Block a user