From 87dec475d39a4fb2ae73e83e222d02101f70699e Mon Sep 17 00:00:00 2001 From: Buseong Kim Date: Fri, 5 Dec 2025 18:38:13 +0900 Subject: [PATCH 1/2] fix(net): fallback multicast join without adapters ifaddr may return no adapters in sandboxed environments, which prevents mDNS multicast membership and leads to empty scan results. Detect a default IPv4 via a dummy UDP connect and use it for IP_MULTICAST_IF/ADD_MEMBERSHIP while keeping the socket bind on an empty string to avoid conflicts. Reuse the same probe fallback in get_private_addresses so callers still obtain a usable IPv4 when adapter enumeration is empty. --- pyatv/support/net.py | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/pyatv/support/net.py b/pyatv/support/net.py index f6a9b913c..c8a37dd59 100644 --- a/pyatv/support/net.py +++ b/pyatv/support/net.py @@ -47,6 +47,26 @@ def mcast_socket(address: Optional[str], port: int = 0) -> socket.socket: with suppress(OSError): membership = socket.inet_aton("224.0.0.251") + socket.inet_aton(address) sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, membership) + else: + # Fallback for sandboxed platforms where ifaddr finds no adapters: + # detect default IPv4 via a dummy UDP connect and use it for multicast join. + try: + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as probe: + probe.connect(("8.8.8.8", 80)) + detected = probe.getsockname()[0] + sock.setsockopt( + socket.SOL_IP, socket.IP_MULTICAST_IF, socket.inet_aton(detected) + ) + membership = socket.inet_aton("224.0.0.251") + socket.inet_aton( + detected + ) + sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, membership) + except OSError: + with suppress(OSError): + membership = socket.inet_aton("224.0.0.251") + socket.inet_aton( + "0.0.0.0" + ) + sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, membership) _LOGGER.debug("Binding on %s:%d", address or "*", port) sock.bind((address or "", port)) @@ -74,6 +94,17 @@ def get_private_addresses(include_loopback=True) -> List[IPv4Address]: if ipaddr.is_private: addresses.append(ipaddr) + # Fallback when no adapters are reported (e.g. sandboxed platforms). + if not addresses: + try: + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as probe: + probe.connect(("8.8.8.8", 80)) + detected = IPv4Address(probe.getsockname()[0]) + if include_loopback or not detected.is_loopback: + addresses.append(detected) + except OSError: + pass + return addresses From ae6e10e2c1c5badda1764ec0973e0edbf2b2bb06 Mon Sep 17 00:00:00 2001 From: Buseong Kim Date: Fri, 23 Jan 2026 19:44:34 +0900 Subject: [PATCH 2/2] fix(net): avoid public IP for route probe --- pyatv/support/net.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/pyatv/support/net.py b/pyatv/support/net.py index c8a37dd59..87ef09735 100644 --- a/pyatv/support/net.py +++ b/pyatv/support/net.py @@ -14,6 +14,9 @@ _LOGGER = logging.getLogger(__name__) +# Private RFC1918 target used only to select a default route. +_DEFAULT_ROUTE_PROBE = ("10.255.255.1", 1) + def unused_port() -> int: """Return a port that is unused on the current host.""" @@ -52,7 +55,7 @@ def mcast_socket(address: Optional[str], port: int = 0) -> socket.socket: # detect default IPv4 via a dummy UDP connect and use it for multicast join. try: with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as probe: - probe.connect(("8.8.8.8", 80)) + probe.connect(_DEFAULT_ROUTE_PROBE) detected = probe.getsockname()[0] sock.setsockopt( socket.SOL_IP, socket.IP_MULTICAST_IF, socket.inet_aton(detected) @@ -98,7 +101,7 @@ def get_private_addresses(include_loopback=True) -> List[IPv4Address]: if not addresses: try: with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as probe: - probe.connect(("8.8.8.8", 80)) + probe.connect(_DEFAULT_ROUTE_PROBE) detected = IPv4Address(probe.getsockname()[0]) if include_loopback or not detected.is_loopback: addresses.append(detected)