From f265d95fd701246d309e5a17cece67a0fa718605 Mon Sep 17 00:00:00 2001 From: Bluetooth Devices Bot Date: Wed, 20 May 2026 20:29:57 +0000 Subject: [PATCH 1/6] feat: add multicast_addresses to bind/multicast independently iOS and other sandboxed platforms allow joining the mDNS multicast group on a physical interface but forbid binding port 5353 on it because the system mDNS daemon owns the port. The existing ``interfaces`` parameter conflates the two: every entry both joins the multicast group and gets a respond socket bound to that interface's address. Add an optional ``multicast_addresses`` parameter on ``Zeroconf`` / ``AsyncZeroconf`` / ``create_sockets`` for additional addresses that are joined to the listen socket's multicast group without creating a respond socket. The default behavior is unchanged. Fixes #1520 --- src/zeroconf/_core.py | 18 +++++++- src/zeroconf/_utils/net.py | 21 +++++++++ src/zeroconf/asyncio.py | 7 ++- tests/test_core.py | 14 ++++++ tests/utils/test_net.py | 91 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 148 insertions(+), 3 deletions(-) diff --git a/src/zeroconf/_core.py b/src/zeroconf/_core.py index f184b6fa..8a710a5d 100644 --- a/src/zeroconf/_core.py +++ b/src/zeroconf/_core.py @@ -27,7 +27,7 @@ import random import sys import threading -from collections.abc import Awaitable +from collections.abc import Awaitable, Sequence from types import TracebackType from ._cache import DNSCache @@ -162,6 +162,7 @@ def __init__( ip_version: IPVersion | None = None, apple_p2p: bool = False, use_asyncio: bool | None = None, + multicast_addresses: Sequence[str | int | tuple[tuple[str, int, int], int]] | None = None, ) -> None: """Creates an instance of the Zeroconf class, establishing multicast communications, listening and reaping threads. @@ -185,6 +186,13 @@ def __init__( already has an event loop (e.g. Jupyter) but you want blocking semantics. ``True`` raises :class:`RuntimeError` immediately if no running event loop is found, instead of falling back to the thread. + :param multicast_addresses: optional list of additional IP addresses to + add to the listen socket's multicast group. No respond socket is + created for these — they extend multicast membership without + requiring a bind on the corresponding interface. Useful on + sandboxed platforms (e.g. iOS) where port 5353 cannot be bound on + physical interfaces but multicast membership is still needed to + receive queries arriving on them. """ if ip_version is None: ip_version = autodetect_ip_version(interfaces) @@ -199,7 +207,13 @@ def __init__( self.unicast = unicast self._use_asyncio = use_asyncio - listen_socket, respond_sockets = create_sockets(interfaces, unicast, ip_version, apple_p2p=apple_p2p) + listen_socket, respond_sockets = create_sockets( + interfaces, + unicast, + ip_version, + apple_p2p=apple_p2p, + multicast_addresses=multicast_addresses, + ) log.debug("Listen socket %s, respond sockets %s", listen_socket, respond_sockets) self.engine = AsyncEngine(self, listen_socket, respond_sockets) diff --git a/src/zeroconf/_utils/net.py b/src/zeroconf/_utils/net.py index 01c5b040..ee9cab11 100644 --- a/src/zeroconf/_utils/net.py +++ b/src/zeroconf/_utils/net.py @@ -444,19 +444,37 @@ def create_sockets( unicast: bool = False, ip_version: IPVersion = IPVersion.V4Only, apple_p2p: bool = False, + multicast_addresses: Sequence[str | int | tuple[tuple[str, int, int], int]] | None = None, ) -> tuple[socket.socket | None, list[socket.socket]]: + """Create the listen and respond sockets. + + ``multicast_addresses`` is an optional list of additional addresses that + are added to the listen socket's multicast group without creating respond + sockets for them. This is useful on sandboxed platforms (notably iOS) + where binding port 5353 on a physical interface is blocked by the system + mDNS daemon but multicast membership on that interface is still required + to receive incoming queries. + """ + if multicast_addresses and unicast: + raise ValueError("multicast_addresses is incompatible with unicast=True") + if unicast: listen_socket = None else: listen_socket = new_socket(bind_addr=("",), ip_version=ip_version, apple_p2p=apple_p2p) normalized_interfaces = normalize_interface_choice(interfaces, ip_version) + extra_multicast_members = ( + normalize_interface_choice(list(multicast_addresses), ip_version) if multicast_addresses else [] + ) # If we are using InterfaceChoice.Default with only IPv4 or only IPv6, we can use # a single socket to listen and respond. if not unicast and interfaces is InterfaceChoice.Default and ip_version != IPVersion.All: for interface in normalized_interfaces: add_multicast_member(cast(socket.socket, listen_socket), interface) + for interface in extra_multicast_members: + add_multicast_member(cast(socket.socket, listen_socket), interface) # Sent responder socket options to the dual-use listen socket set_respond_socket_multicast_options(cast(socket.socket, listen_socket), ip_version) return listen_socket, [cast(socket.socket, listen_socket)] @@ -473,6 +491,9 @@ def create_sockets( if respond_socket is not None: respond_sockets.append(respond_socket) + for interface in extra_multicast_members: + add_multicast_member(cast(socket.socket, listen_socket), interface) + return listen_socket, respond_sockets diff --git a/src/zeroconf/asyncio.py b/src/zeroconf/asyncio.py index 45aac67a..fec2e5bb 100644 --- a/src/zeroconf/asyncio.py +++ b/src/zeroconf/asyncio.py @@ -24,7 +24,7 @@ import asyncio import contextlib -from collections.abc import Awaitable, Callable +from collections.abc import Awaitable, Callable, Sequence from types import TracebackType # used in type hints from ._core import Zeroconf @@ -151,6 +151,7 @@ def __init__( ip_version: IPVersion | None = None, apple_p2p: bool = False, zc: Zeroconf | None = None, + multicast_addresses: Sequence[str | int | tuple[tuple[str, int, int], int]] | None = None, ) -> None: """Creates an instance of the Zeroconf class, establishing multicast communications, and listening. @@ -166,12 +167,16 @@ def __init__( :param ip_version: IP versions to support. If `choice` is a list, the default is detected from it. Otherwise defaults to V4 only for backward compatibility. :param apple_p2p: use AWDL interface (only macOS) + :param multicast_addresses: optional list of additional IP addresses to + add to the listen socket's multicast group; see + :class:`Zeroconf` for details. """ self.zeroconf = zc or Zeroconf( interfaces=interfaces, unicast=unicast, ip_version=ip_version, apple_p2p=apple_p2p, + multicast_addresses=multicast_addresses, ) self.async_browsers: dict[ServiceListener, AsyncServiceBrowser] = {} diff --git a/tests/test_core.py b/tests/test_core.py index 1bc85b1e..b1790729 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -184,6 +184,20 @@ def test_use_asyncio_default_starts_thread_without_loop(self): finally: zc.close() + def test_multicast_addresses_forwarded_to_create_sockets(self): + """Zeroconf forwards multicast_addresses to create_sockets unchanged.""" + with patch("zeroconf._core.create_sockets", return_value=(None, [])) as mock_create: + zc = r.Zeroconf( + interfaces=["127.0.0.1"], + multicast_addresses=["192.168.1.5"], + unicast=True, + ) + try: + _, kwargs = mock_create.call_args + assert kwargs["multicast_addresses"] == ["192.168.1.5"] + finally: + zc.close() + def test_async_updates_from_response(self): def mock_incoming_msg( service_state_change: r.ServiceStateChange, diff --git a/tests/utils/test_net.py b/tests/utils/test_net.py index 311e95e6..338a6b29 100644 --- a/tests/utils/test_net.py +++ b/tests/utils/test_net.py @@ -433,3 +433,94 @@ def call_to_tuple(c): expected_calls_set = {call_to_tuple(c) for c in expected_calls} assert actual_calls_set == expected_calls_set + + +def test_create_sockets_multicast_addresses_v4() -> None: + """Extra IPv4 addresses join the listen socket multicast group but get no respond socket.""" + listen_mock = Mock(spec=socket.socket) + respond_mock = Mock(spec=socket.socket) + + def _new_socket(bind_addr, **kwargs): + return listen_mock if bind_addr == ("",) else respond_mock + + with ( + patch("zeroconf._utils.net.new_socket", side_effect=_new_socket), + patch("zeroconf._utils.net.add_multicast_member", return_value=True) as mock_add, + patch("zeroconf._utils.net.set_respond_socket_multicast_options"), + patch("zeroconf._utils.net.socket.socket.setsockopt"), + ): + listen_socket, respond_sockets = r.create_sockets( + interfaces=["127.0.0.1"], + multicast_addresses=["192.168.1.5", "10.0.0.5"], + ip_version=r.IPVersion.V4Only, + ) + + assert listen_socket is listen_mock + assert respond_sockets == [respond_mock] + joined = [c.args[1] for c in mock_add.call_args_list if c.args[0] is listen_mock] + assert "127.0.0.1" in joined + assert "192.168.1.5" in joined + assert "10.0.0.5" in joined + + +def test_create_sockets_multicast_addresses_v6() -> None: + """Extra IPv6 addresses join the listen socket multicast group.""" + listen_mock = Mock(spec=socket.socket) + respond_mock = Mock(spec=socket.socket) + + def _new_socket(bind_addr, **kwargs): + return listen_mock if bind_addr == ("",) else respond_mock + + with ( + patch("zeroconf._utils.net.new_socket", side_effect=_new_socket), + patch("zeroconf._utils.net.add_multicast_member", return_value=True) as mock_add, + patch("zeroconf._utils.net.set_respond_socket_multicast_options"), + patch( + "zeroconf._utils.net.ifaddr.get_adapters", + return_value=_generate_mock_adapters(), + ), + patch("zeroconf._utils.net.socket.socket.setsockopt"), + ): + r.create_sockets( + interfaces=[1], + multicast_addresses=["2001:db8::"], + ip_version=r.IPVersion.V6Only, + ) + + joined = [c.args[1] for c in mock_add.call_args_list if c.args[0] is listen_mock] + # Both the interface index 1 and the extra multicast address resolve to the + # same adapter tuple — what matters is the listen socket joined that group. + assert (("2001:db8::", 1, 1), 1) in joined + + +def test_create_sockets_multicast_addresses_unicast_rejected() -> None: + """multicast_addresses is incompatible with unicast=True (there is no listen socket).""" + with pytest.raises(ValueError): + r.create_sockets( + interfaces=["127.0.0.1"], + multicast_addresses=["192.168.1.5"], + unicast=True, + ) + + +def test_create_sockets_multicast_addresses_default_path() -> None: + """multicast_addresses also works on the InterfaceChoice.Default fast path.""" + listen_mock = Mock(spec=socket.socket) + + with ( + patch("zeroconf._utils.net.new_socket", return_value=listen_mock), + patch("zeroconf._utils.net.add_multicast_member", return_value=True) as mock_add, + patch("zeroconf._utils.net.set_respond_socket_multicast_options"), + patch("zeroconf._utils.net.socket.socket.setsockopt"), + ): + listen_socket, respond_sockets = r.create_sockets( + interfaces=r.InterfaceChoice.Default, + multicast_addresses=["192.168.1.5"], + ip_version=r.IPVersion.V4Only, + ) + + assert listen_socket is listen_mock + assert respond_sockets == [listen_mock] + joined = [c.args[1] for c in mock_add.call_args_list if c.args[0] is listen_mock] + assert "0.0.0.0" in joined + assert "192.168.1.5" in joined From 933a7c98742c0e39d5c39ffdb3145658c8f4b4c2 Mon Sep 17 00:00:00 2001 From: Bluetooth Devices Bot Date: Wed, 20 May 2026 21:41:03 +0000 Subject: [PATCH 2/6] feat: validate and autodetect ip_version for multicast_addresses, dedup against interfaces --- src/zeroconf/_core.py | 2 +- src/zeroconf/_utils/net.py | 49 ++++++++++++++++++++++++++++------- tests/test_core.py | 1 - tests/utils/test_net.py | 52 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 93 insertions(+), 11 deletions(-) diff --git a/src/zeroconf/_core.py b/src/zeroconf/_core.py index 8a710a5d..80de1794 100644 --- a/src/zeroconf/_core.py +++ b/src/zeroconf/_core.py @@ -195,7 +195,7 @@ def __init__( receive queries arriving on them. """ if ip_version is None: - ip_version = autodetect_ip_version(interfaces) + ip_version = autodetect_ip_version(interfaces, multicast_addresses) self.done = False diff --git a/src/zeroconf/_utils/net.py b/src/zeroconf/_utils/net.py index ee9cab11..358cb115 100644 --- a/src/zeroconf/_utils/net.py +++ b/src/zeroconf/_utils/net.py @@ -458,15 +458,34 @@ def create_sockets( if multicast_addresses and unicast: raise ValueError("multicast_addresses is incompatible with unicast=True") + # Reject IP-version-incompatible entries up front so callers get a clear + # error instead of a confusing adapter-lookup or socket-syscall failure. + if multicast_addresses: + if ip_version == IPVersion.V4Only: + for entry in multicast_addresses: + if isinstance(entry, (int, tuple)) or ( + isinstance(entry, str) and ipaddress.ip_address(entry).version == 6 + ): + raise ValueError("multicast_addresses contains IPv6 entries but ip_version is V4Only") + elif ip_version == IPVersion.V6Only: + for entry in multicast_addresses: + if isinstance(entry, str) and ipaddress.ip_address(entry).version == 4: + raise ValueError("multicast_addresses contains IPv4 entries but ip_version is V6Only") + if unicast: listen_socket = None else: listen_socket = new_socket(bind_addr=("",), ip_version=ip_version, apple_p2p=apple_p2p) normalized_interfaces = normalize_interface_choice(interfaces, ip_version) - extra_multicast_members = ( - normalize_interface_choice(list(multicast_addresses), ip_version) if multicast_addresses else [] - ) + if multicast_addresses: + extra_multicast_members = normalize_interface_choice(list(multicast_addresses), ip_version) + # Strip entries already covered by ``interfaces`` so add_multicast_member + # is not called twice for the same membership. + interface_set = set(normalized_interfaces) + extra_multicast_members = [m for m in extra_multicast_members if m not in interface_set] + else: + extra_multicast_members = [] # If we are using InterfaceChoice.Default with only IPv4 or only IPv6, we can use # a single socket to listen and respond. @@ -510,17 +529,29 @@ def can_send_to(ipv6_socket: bool, address: str) -> bool: return ":" in address if ipv6_socket else ":" not in address -def autodetect_ip_version(interfaces: InterfacesType) -> IPVersion: +def autodetect_ip_version( + interfaces: InterfacesType, + multicast_addresses: Sequence[str | int | tuple[tuple[str, int, int], int]] | None = None, +) -> IPVersion: """Auto detect the IP version when it is not provided.""" + has_v6 = False + has_v4 = False if isinstance(interfaces, list): has_v6 = any( isinstance(i, int) or (isinstance(i, str) and ipaddress.ip_address(i).version == 6) for i in interfaces ) has_v4 = any(isinstance(i, str) and ipaddress.ip_address(i).version == 4 for i in interfaces) - if has_v4 and has_v6: - return IPVersion.All - if has_v6: - return IPVersion.V6Only - + if multicast_addresses: + has_v6 = has_v6 or any( + isinstance(i, (int, tuple)) or (isinstance(i, str) and ipaddress.ip_address(i).version == 6) + for i in multicast_addresses + ) + has_v4 = has_v4 or any( + isinstance(i, str) and ipaddress.ip_address(i).version == 4 for i in multicast_addresses + ) + if has_v4 and has_v6: + return IPVersion.All + if has_v6: + return IPVersion.V6Only return IPVersion.V4Only diff --git a/tests/test_core.py b/tests/test_core.py index b1790729..30ec91d9 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -190,7 +190,6 @@ def test_multicast_addresses_forwarded_to_create_sockets(self): zc = r.Zeroconf( interfaces=["127.0.0.1"], multicast_addresses=["192.168.1.5"], - unicast=True, ) try: _, kwargs = mock_create.call_args diff --git a/tests/utils/test_net.py b/tests/utils/test_net.py index 338a6b29..0656f73c 100644 --- a/tests/utils/test_net.py +++ b/tests/utils/test_net.py @@ -524,3 +524,55 @@ def test_create_sockets_multicast_addresses_default_path() -> None: joined = [c.args[1] for c in mock_add.call_args_list if c.args[0] is listen_mock] assert "0.0.0.0" in joined assert "192.168.1.5" in joined + + +def test_create_sockets_multicast_addresses_v4_rejects_v6_entry() -> None: + """V4Only listen socket rejects IPv6 multicast_addresses entries.""" + with pytest.raises(ValueError, match="IPv6"): + r.create_sockets( + interfaces=["127.0.0.1"], + multicast_addresses=["2001:db8::"], + ip_version=r.IPVersion.V4Only, + ) + + +def test_create_sockets_multicast_addresses_v6_rejects_v4_entry() -> None: + """V6Only listen socket rejects IPv4 multicast_addresses entries.""" + with pytest.raises(ValueError, match="IPv4"): + r.create_sockets( + interfaces=[1], + multicast_addresses=["192.168.1.5"], + ip_version=r.IPVersion.V6Only, + ) + + +def test_create_sockets_multicast_addresses_deduped_against_interfaces() -> None: + """Addresses present in both interfaces and multicast_addresses join only once.""" + listen_mock = Mock(spec=socket.socket) + respond_mock = Mock(spec=socket.socket) + + def _new_socket(bind_addr, **kwargs): + return listen_mock if bind_addr == ("",) else respond_mock + + with ( + patch("zeroconf._utils.net.new_socket", side_effect=_new_socket), + patch("zeroconf._utils.net.add_multicast_member", return_value=True) as mock_add, + patch("zeroconf._utils.net.set_respond_socket_multicast_options"), + patch("zeroconf._utils.net.socket.socket.setsockopt"), + ): + r.create_sockets( + interfaces=["127.0.0.1"], + multicast_addresses=["127.0.0.1", "192.168.1.5"], + ip_version=r.IPVersion.V4Only, + ) + + joined_127 = [c for c in mock_add.call_args_list if c.args[0] is listen_mock and c.args[1] == "127.0.0.1"] + assert len(joined_127) == 1 + + +def test_autodetect_ip_version_includes_multicast_addresses() -> None: + """autodetect_ip_version sees IPv6 entries from multicast_addresses.""" + assert ( + netutils.autodetect_ip_version(["127.0.0.1"], multicast_addresses=["2001:db8::"]) is r.IPVersion.All + ) + assert netutils.autodetect_ip_version([], multicast_addresses=["2001:db8::"]) is r.IPVersion.V6Only From 5fc8e961f8761c334f540a0db61656f83e3f4db7 Mon Sep 17 00:00:00 2001 From: Bluetooth Devices Bot Date: Wed, 20 May 2026 23:02:34 +0000 Subject: [PATCH 3/6] test: cover partial branches for multicast_addresses validation and autodetect --- tests/utils/test_net.py | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/tests/utils/test_net.py b/tests/utils/test_net.py index 0656f73c..9405ef05 100644 --- a/tests/utils/test_net.py +++ b/tests/utils/test_net.py @@ -576,3 +576,39 @@ def test_autodetect_ip_version_includes_multicast_addresses() -> None: netutils.autodetect_ip_version(["127.0.0.1"], multicast_addresses=["2001:db8::"]) is r.IPVersion.All ) assert netutils.autodetect_ip_version([], multicast_addresses=["2001:db8::"]) is r.IPVersion.V6Only + + +def test_autodetect_ip_version_non_list_interfaces_with_multicast_addresses() -> None: + """autodetect_ip_version handles non-list interfaces (InterfaceChoice) with multicast_addresses.""" + assert ( + netutils.autodetect_ip_version(r.InterfaceChoice.Default, multicast_addresses=["2001:db8::"]) + is r.IPVersion.V6Only + ) + assert ( + netutils.autodetect_ip_version(r.InterfaceChoice.Default, multicast_addresses=["192.168.1.5"]) + is r.IPVersion.V4Only + ) + + +def test_create_sockets_multicast_addresses_ip_version_all() -> None: + """multicast_addresses works with ip_version=All (no V4Only/V6Only validation triggered).""" + listen_mock = Mock(spec=socket.socket) + respond_mock = Mock(spec=socket.socket) + + def _new_socket(bind_addr, **kwargs): + return listen_mock if bind_addr == ("",) else respond_mock + + with ( + patch("zeroconf._utils.net.new_socket", side_effect=_new_socket), + patch("zeroconf._utils.net.add_multicast_member", return_value=True) as mock_add, + patch("zeroconf._utils.net.set_respond_socket_multicast_options"), + patch("zeroconf._utils.net.socket.socket.setsockopt"), + ): + r.create_sockets( + interfaces=["127.0.0.1"], + multicast_addresses=["192.168.1.5"], + ip_version=r.IPVersion.All, + ) + + joined = [c.args[1] for c in mock_add.call_args_list if c.args[0] is listen_mock] + assert "192.168.1.5" in joined From 83fadb1c88ac5843153d6a6aad29596f7779bfbe Mon Sep 17 00:00:00 2001 From: Bluetooth Devices Bot Date: Thu, 21 May 2026 13:04:50 +0000 Subject: [PATCH 4/6] refactor: extract _entry_ip_version helper to DRY multicast_addresses validation and autodetect --- src/zeroconf/_utils/net.py | 44 +++++++++++++++++--------------------- 1 file changed, 20 insertions(+), 24 deletions(-) diff --git a/src/zeroconf/_utils/net.py b/src/zeroconf/_utils/net.py index 358cb115..0d73419a 100644 --- a/src/zeroconf/_utils/net.py +++ b/src/zeroconf/_utils/net.py @@ -439,6 +439,17 @@ def new_respond_socket( return respond_socket +def _entry_ip_version(entry: str | int | tuple[tuple[str, int, int], int]) -> int: + """Return 4 or 6 for the IP version implied by an interface / multicast entry. + + ``int`` (interface index) and ``tuple`` (ifaddr IPv6 adapter tuple) entries + are IPv6 by construction; ``str`` entries are parsed as IP addresses. + """ + if isinstance(entry, str): + return ipaddress.ip_address(entry).version + return 6 + + def create_sockets( interfaces: InterfacesType = InterfaceChoice.All, unicast: bool = False, @@ -461,16 +472,10 @@ def create_sockets( # Reject IP-version-incompatible entries up front so callers get a clear # error instead of a confusing adapter-lookup or socket-syscall failure. if multicast_addresses: - if ip_version == IPVersion.V4Only: - for entry in multicast_addresses: - if isinstance(entry, (int, tuple)) or ( - isinstance(entry, str) and ipaddress.ip_address(entry).version == 6 - ): - raise ValueError("multicast_addresses contains IPv6 entries but ip_version is V4Only") - elif ip_version == IPVersion.V6Only: - for entry in multicast_addresses: - if isinstance(entry, str) and ipaddress.ip_address(entry).version == 4: - raise ValueError("multicast_addresses contains IPv4 entries but ip_version is V6Only") + if ip_version == IPVersion.V4Only and any(_entry_ip_version(e) == 6 for e in multicast_addresses): + raise ValueError("multicast_addresses contains IPv6 entries but ip_version is V4Only") + if ip_version == IPVersion.V6Only and any(_entry_ip_version(e) == 4 for e in multicast_addresses): + raise ValueError("multicast_addresses contains IPv4 entries but ip_version is V6Only") if unicast: listen_socket = None @@ -534,22 +539,13 @@ def autodetect_ip_version( multicast_addresses: Sequence[str | int | tuple[tuple[str, int, int], int]] | None = None, ) -> IPVersion: """Auto detect the IP version when it is not provided.""" - has_v6 = False - has_v4 = False + entries: list[str | int | tuple[tuple[str, int, int], int]] = [] if isinstance(interfaces, list): - has_v6 = any( - isinstance(i, int) or (isinstance(i, str) and ipaddress.ip_address(i).version == 6) - for i in interfaces - ) - has_v4 = any(isinstance(i, str) and ipaddress.ip_address(i).version == 4 for i in interfaces) + entries.extend(interfaces) if multicast_addresses: - has_v6 = has_v6 or any( - isinstance(i, (int, tuple)) or (isinstance(i, str) and ipaddress.ip_address(i).version == 6) - for i in multicast_addresses - ) - has_v4 = has_v4 or any( - isinstance(i, str) and ipaddress.ip_address(i).version == 4 for i in multicast_addresses - ) + entries.extend(multicast_addresses) + has_v6 = any(_entry_ip_version(e) == 6 for e in entries) + has_v4 = any(_entry_ip_version(e) == 4 for e in entries) if has_v4 and has_v6: return IPVersion.All if has_v6: From 56f00c5a85d339a4c1aec50d8b6632aa203c1faa Mon Sep 17 00:00:00 2001 From: Bluetooth Devices Bot Date: Thu, 21 May 2026 23:26:59 +0000 Subject: [PATCH 5/6] test: cover interface-index autodetection branch in _entry_ip_version --- tests/test_core.py | 76 +++++++++++++++++++++-------------------- tests/utils/test_net.py | 6 ++++ 2 files changed, 45 insertions(+), 37 deletions(-) diff --git a/tests/test_core.py b/tests/test_core.py index 30ec91d9..bbcc2620 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -712,43 +712,45 @@ def test_tc_bit_defers_last_response_missing(): assert len(packets) == 4 expected_deferred = [] - # Pin per-packet delay to the minimum so each successive fire_at lands - # before the deadline established by the first packet — keeps the - # timer-replacement assertions deterministic under bounded TC-deferral. - min_delay_ms = _TC_DELAY_RANDOM_INTERVAL[0] - with patch("zeroconf._listener.random.randint", return_value=min_delay_ms): - next_packet = r.DNSIncoming(packets.pop(0)) - expected_deferred.append(next_packet) - threadsafe_query(zc, protocol, next_packet, source_ip, const._MDNS_PORT, Mock(), ()) - assert protocol._deferred[source_ip] == expected_deferred - timer1 = protocol._timers[source_ip] - - next_packet = r.DNSIncoming(packets.pop(0)) - expected_deferred.append(next_packet) - threadsafe_query(zc, protocol, next_packet, source_ip, const._MDNS_PORT, Mock(), ()) - assert protocol._deferred[source_ip] == expected_deferred - timer2 = protocol._timers[source_ip] - assert timer1.cancelled() - assert timer2 != timer1 - - # Send the same packet again to similar multi interfaces - threadsafe_query(zc, protocol, next_packet, source_ip, const._MDNS_PORT, Mock(), ()) - assert protocol._deferred[source_ip] == expected_deferred - assert source_ip in protocol._timers - timer3 = protocol._timers[source_ip] - assert not timer3.cancelled() - assert timer3 == timer2 - - next_packet = r.DNSIncoming(packets.pop(0)) - expected_deferred.append(next_packet) - threadsafe_query(zc, protocol, next_packet, source_ip, const._MDNS_PORT, Mock(), ()) - assert protocol._deferred[source_ip] == expected_deferred - assert source_ip in protocol._timers - timer4 = protocol._timers[source_ip] - assert timer3.cancelled() - assert timer4 != timer3 - - for _ in range(8): + # Widen the per-packet delay so Windows scheduling jitter between + # assertions cannot fire the timer mid-test, while leaving plenty of + # headroom under the first-arrival deadline for timer-replacement to + # remain deterministic. + with patch.object(_listener, "_TC_DELAY_RANDOM_INTERVAL", (1500, 60_000)): + min_delay_ms = _listener._TC_DELAY_RANDOM_INTERVAL[0] + with patch("zeroconf._listener.random.randint", return_value=min_delay_ms): + next_packet = r.DNSIncoming(packets.pop(0)) + expected_deferred.append(next_packet) + threadsafe_query(zc, protocol, next_packet, source_ip, const._MDNS_PORT, Mock(), ()) + assert protocol._deferred[source_ip] == expected_deferred + timer1 = protocol._timers[source_ip] + + next_packet = r.DNSIncoming(packets.pop(0)) + expected_deferred.append(next_packet) + threadsafe_query(zc, protocol, next_packet, source_ip, const._MDNS_PORT, Mock(), ()) + assert protocol._deferred[source_ip] == expected_deferred + timer2 = protocol._timers[source_ip] + assert timer1.cancelled() + assert timer2 != timer1 + + # Send the same packet again to similar multi interfaces + threadsafe_query(zc, protocol, next_packet, source_ip, const._MDNS_PORT, Mock(), ()) + assert protocol._deferred[source_ip] == expected_deferred + assert source_ip in protocol._timers + timer3 = protocol._timers[source_ip] + assert not timer3.cancelled() + assert timer3 == timer2 + + next_packet = r.DNSIncoming(packets.pop(0)) + expected_deferred.append(next_packet) + threadsafe_query(zc, protocol, next_packet, source_ip, const._MDNS_PORT, Mock(), ()) + assert protocol._deferred[source_ip] == expected_deferred + assert source_ip in protocol._timers + timer4 = protocol._timers[source_ip] + assert timer3.cancelled() + assert timer4 != timer3 + + for _ in range(30): time.sleep(0.1) if source_ip not in protocol._timers and source_ip not in protocol._deferred: break diff --git a/tests/utils/test_net.py b/tests/utils/test_net.py index 9405ef05..f5343a8b 100644 --- a/tests/utils/test_net.py +++ b/tests/utils/test_net.py @@ -590,6 +590,12 @@ def test_autodetect_ip_version_non_list_interfaces_with_multicast_addresses() -> ) +def test_autodetect_ip_version_interface_index_treated_as_v6() -> None: + """Interface-index (int) entries are treated as IPv6 by autodetection.""" + assert netutils.autodetect_ip_version([1]) is r.IPVersion.V6Only + assert netutils.autodetect_ip_version(["1.2.3.4", 1]) is r.IPVersion.All + + def test_create_sockets_multicast_addresses_ip_version_all() -> None: """multicast_addresses works with ip_version=All (no V4Only/V6Only validation triggered).""" listen_mock = Mock(spec=socket.socket) From 6e25708e93dc302cb28758ce2b47830e24843a7b Mon Sep 17 00:00:00 2001 From: Bluetooth Devices Bot Date: Fri, 22 May 2026 00:10:11 +0000 Subject: [PATCH 6/6] fix: preserve pre-normalized tuple entries in multicast_addresses --- src/zeroconf/_utils/net.py | 2 ++ tests/utils/test_net.py | 26 ++++++++++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/src/zeroconf/_utils/net.py b/src/zeroconf/_utils/net.py index 0d73419a..c84967bc 100644 --- a/src/zeroconf/_utils/net.py +++ b/src/zeroconf/_utils/net.py @@ -152,6 +152,8 @@ def ip6_addresses_to_indexes( result.append((interface_index_to_ip6_address(adapters, iface), iface)) # type: ignore[arg-type] elif isinstance(iface, str) and ipaddress.ip_address(iface).version == 6: result.append(ip6_to_address_and_index(adapters, iface)) # type: ignore[arg-type] + elif isinstance(iface, tuple): + result.append(iface) return result diff --git a/tests/utils/test_net.py b/tests/utils/test_net.py index f5343a8b..1aec29de 100644 --- a/tests/utils/test_net.py +++ b/tests/utils/test_net.py @@ -618,3 +618,29 @@ def _new_socket(bind_addr, **kwargs): joined = [c.args[1] for c in mock_add.call_args_list if c.args[0] is listen_mock] assert "192.168.1.5" in joined + + +def test_create_sockets_multicast_addresses_preserves_pre_normalized_tuple() -> None: + """Pre-normalized IPv6 adapter tuples pass through to add_multicast_member.""" + listen_mock = Mock(spec=socket.socket) + + pre_normalized = (("2001:db8::", 1, 1), 1) + + with ( + patch("zeroconf._utils.net.new_socket", return_value=listen_mock), + patch("zeroconf._utils.net.add_multicast_member", return_value=True) as mock_add, + patch("zeroconf._utils.net.set_respond_socket_multicast_options"), + patch( + "zeroconf._utils.net.ifaddr.get_adapters", + return_value=_generate_mock_adapters(), + ), + patch("zeroconf._utils.net.socket.socket.setsockopt"), + ): + r.create_sockets( + interfaces=[], + multicast_addresses=[pre_normalized], + ip_version=r.IPVersion.V6Only, + ) + + joined = [c.args[1] for c in mock_add.call_args_list if c.args[0] is listen_mock] + assert pre_normalized in joined