mirror of
https://github.com/home-assistant/core.git
synced 2025-07-27 07:07:28 +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
|
import fnmatch
|
||||||
from functools import partial
|
from functools import partial
|
||||||
import ipaddress
|
import ipaddress
|
||||||
|
from ipaddress import ip_address
|
||||||
import logging
|
import logging
|
||||||
import socket
|
import socket
|
||||||
from typing import Any, TypedDict
|
from typing import Any, Iterable, TypedDict, cast
|
||||||
|
|
||||||
|
from pyroute2 import IPRoute
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
from zeroconf import (
|
from zeroconf import (
|
||||||
Error as ZeroconfError,
|
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.network import NoURLAvailableError, get_url
|
||||||
from homeassistant.helpers.singleton import singleton
|
from homeassistant.helpers.singleton import singleton
|
||||||
from homeassistant.loader import async_get_homekit, async_get_zeroconf
|
from homeassistant.loader import async_get_homekit, async_get_zeroconf
|
||||||
|
from homeassistant.util.network import is_loopback
|
||||||
|
|
||||||
from .models import HaServiceBrowser, HaZeroconf
|
from .models import HaServiceBrowser, HaZeroconf
|
||||||
from .usage import install_multiple_zeroconf_catcher
|
from .usage import install_multiple_zeroconf_catcher
|
||||||
@ -55,6 +58,8 @@ DEFAULT_IPV6 = True
|
|||||||
HOMEKIT_PAIRED_STATUS_FLAG = "sf"
|
HOMEKIT_PAIRED_STATUS_FLAG = "sf"
|
||||||
HOMEKIT_MODEL = "md"
|
HOMEKIT_MODEL = "md"
|
||||||
|
|
||||||
|
MDNS_TARGET_IP = "224.0.0.251"
|
||||||
|
|
||||||
# Property key=value has a max length of 255
|
# Property key=value has a max length of 255
|
||||||
# so we use 230 to leave space for key=
|
# so we use 230 to leave space for key=
|
||||||
MAX_PROPERTY_VALUE_LEN = 230
|
MAX_PROPERTY_VALUE_LEN = 230
|
||||||
@ -66,9 +71,7 @@ CONFIG_SCHEMA = vol.Schema(
|
|||||||
{
|
{
|
||||||
DOMAIN: vol.Schema(
|
DOMAIN: vol.Schema(
|
||||||
{
|
{
|
||||||
vol.Optional(
|
vol.Optional(CONF_DEFAULT_INTERFACE): cv.boolean,
|
||||||
CONF_DEFAULT_INTERFACE, default=DEFAULT_DEFAULT_INTERFACE
|
|
||||||
): cv.boolean,
|
|
||||||
vol.Optional(CONF_IPV6, default=DEFAULT_IPV6): 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
|
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:
|
async def async_setup(hass: HomeAssistant, config: dict) -> bool:
|
||||||
"""Set up Zeroconf and make Home Assistant discoverable."""
|
"""Set up Zeroconf and make Home Assistant discoverable."""
|
||||||
zc_config = config.get(DOMAIN, {})
|
zc_config = config.get(DOMAIN, {})
|
||||||
zc_args: dict = {}
|
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
|
zc_args["interfaces"] = InterfaceChoice.Default
|
||||||
if not zc_config.get(CONF_IPV6, DEFAULT_IPV6):
|
if not zc_config.get(CONF_IPV6, DEFAULT_IPV6):
|
||||||
zc_args["ip_version"] = IPVersion.V4Only
|
zc_args["ip_version"] = IPVersion.V4Only
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
"domain": "zeroconf",
|
"domain": "zeroconf",
|
||||||
"name": "Zero-configuration networking (zeroconf)",
|
"name": "Zero-configuration networking (zeroconf)",
|
||||||
"documentation": "https://www.home-assistant.io/integrations/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"],
|
"dependencies": ["api"],
|
||||||
"codeowners": ["@bdraco"],
|
"codeowners": ["@bdraco"],
|
||||||
"quality_scale": "internal",
|
"quality_scale": "internal",
|
||||||
|
@ -23,6 +23,7 @@ netdisco==2.8.2
|
|||||||
paho-mqtt==1.5.1
|
paho-mqtt==1.5.1
|
||||||
pillow==8.1.2
|
pillow==8.1.2
|
||||||
pip>=8.0.3,<20.3
|
pip>=8.0.3,<20.3
|
||||||
|
pyroute2==0.5.18
|
||||||
python-slugify==4.0.1
|
python-slugify==4.0.1
|
||||||
pytz>=2021.1
|
pytz>=2021.1
|
||||||
pyyaml==5.4.1
|
pyyaml==5.4.1
|
||||||
|
@ -1672,6 +1672,9 @@ pyrisco==0.3.1
|
|||||||
# homeassistant.components.rituals_perfume_genie
|
# homeassistant.components.rituals_perfume_genie
|
||||||
pyrituals==0.0.2
|
pyrituals==0.0.2
|
||||||
|
|
||||||
|
# homeassistant.components.zeroconf
|
||||||
|
pyroute2==0.5.18
|
||||||
|
|
||||||
# homeassistant.components.ruckus_unleashed
|
# homeassistant.components.ruckus_unleashed
|
||||||
pyruckus==0.12
|
pyruckus==0.12
|
||||||
|
|
||||||
|
@ -920,6 +920,9 @@ pyrisco==0.3.1
|
|||||||
# homeassistant.components.rituals_perfume_genie
|
# homeassistant.components.rituals_perfume_genie
|
||||||
pyrituals==0.0.2
|
pyrituals==0.0.2
|
||||||
|
|
||||||
|
# homeassistant.components.zeroconf
|
||||||
|
pyroute2==0.5.18
|
||||||
|
|
||||||
# homeassistant.components.ruckus_unleashed
|
# homeassistant.components.ruckus_unleashed
|
||||||
pyruckus==0.12
|
pyruckus==0.12
|
||||||
|
|
||||||
|
@ -31,6 +31,27 @@ PROPERTIES = {
|
|||||||
HOMEKIT_STATUS_UNPAIRED = b"1"
|
HOMEKIT_STATUS_UNPAIRED = b"1"
|
||||||
HOMEKIT_STATUS_PAIRED = b"0"
|
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):
|
def service_update_mock(zeroconf, services, handlers, *, limit_service=None):
|
||||||
"""Call service update handler."""
|
"""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 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[0][1][0] == "_service.added"
|
||||||
assert mock_zeroconf.get_service_info.mock_calls[1][1][0] == "_service.updated"
|
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