diff --git a/homeassistant/components/zeroconf/__init__.py b/homeassistant/components/zeroconf/__init__.py index 17cfb9d05de..4afb0a3c24d 100644 --- a/homeassistant/components/zeroconf/__init__.py +++ b/homeassistant/components/zeroconf/__init__.py @@ -5,9 +5,10 @@ import asyncio from collections.abc import Coroutine from contextlib import suppress import fnmatch -from ipaddress import IPv6Address, ip_address +from ipaddress import IPv4Address, IPv6Address, ip_address import logging import socket +import sys from typing import Any, TypedDict, cast import voluptuous as vol @@ -131,18 +132,31 @@ async def _async_get_instance(hass: HomeAssistant, **zcargs: Any) -> HaAsyncZero return aio_zc +@callback +def _async_zc_has_functional_dual_stack() -> bool: + """Return true for platforms that not support IP_ADD_MEMBERSHIP on an AF_INET6 socket. + + Zeroconf only supports a single listen socket at this time. + """ + return not sys.platform.startswith("freebsd") and not sys.platform.startswith( + "darwin" + ) + + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Zeroconf and make Home Assistant discoverable.""" - zc_args: dict = {} + zc_args: dict = {"ip_version": IPVersion.V4Only} adapters = await network.async_get_adapters(hass) - ipv6 = True - if not any(adapter["enabled"] and adapter["ipv6"] for adapter in adapters): - ipv6 = False - zc_args["ip_version"] = IPVersion.V4Only - else: - zc_args["ip_version"] = IPVersion.All + ipv6 = False + if _async_zc_has_functional_dual_stack(): + if any(adapter["enabled"] and adapter["ipv6"] for adapter in adapters): + ipv6 = True + zc_args["ip_version"] = IPVersion.All + elif not any(adapter["enabled"] and adapter["ipv4"] for adapter in adapters): + zc_args["ip_version"] = IPVersion.V6Only + ipv6 = True if not ipv6 and network.async_only_default_interface_enabled(adapters): zc_args["interfaces"] = InterfaceChoice.Default @@ -152,6 +166,14 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: for source_ip in await network.async_get_enabled_source_ips(hass) if not source_ip.is_loopback and not (isinstance(source_ip, IPv6Address) and source_ip.is_global) + and not ( + isinstance(source_ip, IPv6Address) + and zc_args["ip_version"] == IPVersion.V4Only + ) + and not ( + isinstance(source_ip, IPv4Address) + and zc_args["ip_version"] == IPVersion.V6Only + ) ] aio_zc = await _async_get_instance(hass, **zc_args) diff --git a/tests/components/zeroconf/test_init.py b/tests/components/zeroconf/test_init.py index b8ce28b6259..0d3c5fc7792 100644 --- a/tests/components/zeroconf/test_init.py +++ b/tests/components/zeroconf/test_init.py @@ -779,11 +779,13 @@ _ADAPTERS_WITH_MANUAL_CONFIG = [ ] -async def test_async_detect_interfaces_setting_empty_route(hass, mock_async_zeroconf): - """Test without default interface config and the route returns nothing.""" - with patch("homeassistant.components.zeroconf.HaZeroconf") as mock_zc, patch.object( - hass.config_entries.flow, "async_init" - ), patch.object( +async def test_async_detect_interfaces_setting_empty_route_linux( + hass, mock_async_zeroconf +): + """Test without default interface config and the route returns nothing on linux.""" + with patch("homeassistant.components.zeroconf.sys.platform", "linux"), patch( + "homeassistant.components.zeroconf.HaZeroconf" + ) as mock_zc, patch.object(hass.config_entries.flow, "async_init"), patch.object( zeroconf, "HaAsyncServiceBrowser", side_effect=service_update_mock ), patch( "homeassistant.components.zeroconf.network.async_get_adapters", @@ -807,6 +809,33 @@ async def test_async_detect_interfaces_setting_empty_route(hass, mock_async_zero ) +async def test_async_detect_interfaces_setting_empty_route_freebsd( + hass, mock_async_zeroconf +): + """Test without default interface config and the route returns nothing on freebsd.""" + with patch("homeassistant.components.zeroconf.sys.platform", "freebsd"), patch( + "homeassistant.components.zeroconf.HaZeroconf" + ) as mock_zc, patch.object(hass.config_entries.flow, "async_init"), patch.object( + zeroconf, "HaAsyncServiceBrowser", side_effect=service_update_mock + ), patch( + "homeassistant.components.zeroconf.network.async_get_adapters", + return_value=_ADAPTERS_WITH_MANUAL_CONFIG, + ), patch( + "homeassistant.components.zeroconf.AsyncServiceInfo", + 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_zc.mock_calls[0] == call( + interfaces=[ + "192.168.1.5", + "172.16.1.5", + ], + ip_version=IPVersion.V4Only, + ) + + async def test_get_announced_addresses(hass, mock_async_zeroconf): """Test addresses for mDNS announcement.""" expected = { @@ -848,11 +877,13 @@ _ADAPTER_WITH_DEFAULT_ENABLED_AND_IPV6 = [ ] -async def test_async_detect_interfaces_explicitly_set_ipv6(hass, mock_async_zeroconf): - """Test interfaces are explicitly set when IPv6 is present.""" - with patch("homeassistant.components.zeroconf.HaZeroconf") as mock_zc, patch.object( - hass.config_entries.flow, "async_init" - ), patch.object( +async def test_async_detect_interfaces_explicitly_set_ipv6_linux( + hass, mock_async_zeroconf +): + """Test interfaces are explicitly set when IPv6 is present on linux.""" + with patch("homeassistant.components.zeroconf.sys.platform", "linux"), patch( + "homeassistant.components.zeroconf.HaZeroconf" + ) as mock_zc, patch.object(hass.config_entries.flow, "async_init"), patch.object( zeroconf, "HaAsyncServiceBrowser", side_effect=service_update_mock ), patch( "homeassistant.components.zeroconf.network.async_get_adapters", @@ -871,6 +902,31 @@ async def test_async_detect_interfaces_explicitly_set_ipv6(hass, mock_async_zero ) +async def test_async_detect_interfaces_explicitly_set_ipv6_freebsd( + hass, mock_async_zeroconf +): + """Test interfaces are explicitly set when IPv6 is present on freebsd.""" + with patch("homeassistant.components.zeroconf.sys.platform", "freebsd"), patch( + "homeassistant.components.zeroconf.HaZeroconf" + ) as mock_zc, patch.object(hass.config_entries.flow, "async_init"), patch.object( + zeroconf, "HaAsyncServiceBrowser", side_effect=service_update_mock + ), patch( + "homeassistant.components.zeroconf.network.async_get_adapters", + return_value=_ADAPTER_WITH_DEFAULT_ENABLED_AND_IPV6, + ), patch( + "homeassistant.components.zeroconf.AsyncServiceInfo", + 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_zc.mock_calls[0] == call( + interfaces=InterfaceChoice.Default, + ip_version=IPVersion.V4Only, + ) + + async def test_no_name(hass, mock_async_zeroconf): """Test fallback to Home for mDNS announcement if the name is missing.""" hass.config.location_name = ""