[socket] Add join_multicast_group() and set_ipv6_multicast_if() helpers (#17045)

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
This commit is contained in:
rwrozelle
2026-10-11 09:59:44 -10:00
committed by GitHub
co-authored by Claude Sonnet 4.6 Clyde Stubbs Copilot Autofix powered by AI J. Nick Koston
parent b73f151619
commit f562d7a6a4
4 changed files with 330 additions and 0 deletions
+207
View File
@@ -11,6 +11,16 @@
#ifdef USE_HOST
#include "esphome/core/wake.h"
#endif
#if defined(USE_HOST)
#include <ifaddrs.h>
#include <net/if.h>
#elif defined(USE_ZEPHYR)
#ifndef CLANG_TIDY // NCS 2.9.2 net_if.h has a BUILD_ASSERT clang rejects; gone in NCS 3.4
#include <zephyr/net/net_if.h>
#endif
#else
#include "lwip/netif.h"
#endif
namespace esphome::socket {
@@ -139,6 +149,203 @@ std::unique_ptr<ListenSocket> socket_ip_loop_monitored(int type, int protocol) {
}
#endif
#if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS)
// Callback must be a temporary lambda
template<typename F> static bool foreach_eligible_ipv6_if(F &&callback) {
#if defined(USE_HOST)
struct ifaddrs *ifaddr;
if (getifaddrs(&ifaddr) != 0) {
return false;
}
bool found_any = false;
for (struct ifaddrs *ifa = ifaddr; ifa != nullptr; ifa = ifa->ifa_next) {
if (ifa->ifa_addr == nullptr || ifa->ifa_addr->sa_family != AF_INET6) {
continue;
}
if ((ifa->ifa_flags & IFF_LOOPBACK) || !(ifa->ifa_flags & IFF_UP) || !(ifa->ifa_flags & IFF_MULTICAST)) {
continue;
}
unsigned int idx = if_nametoindex(ifa->ifa_name);
if (idx == 0) {
continue;
}
found_any = true;
if (callback(idx)) {
break;
}
}
freeifaddrs(ifaddr);
if (!found_any) {
errno = EADDRNOTAVAIL;
}
return found_any;
#elif defined(USE_ZEPHYR) && defined(CLANG_TIDY)
(void) callback;
return false;
#elif defined(USE_ZEPHYR)
// Zephyr has no getifaddrs()/ifaddrs.h; enumerate interfaces via the native net_if API.
// NET_IF_IPV6_NO_MLD is the direct analog of the LwIP branch's NETIF_FLAG_MLD6 check below.
struct EligibleIfContext {
F *callback;
bool found_any = false;
bool done = false;
};
EligibleIfContext ctx{&callback};
net_if_foreach(
[](struct net_if *iface, void *user_data) {
auto *ctx = static_cast<EligibleIfContext *>(user_data);
if (ctx->done) {
return;
}
if (!net_if_flag_is_set(iface, NET_IF_UP) || !net_if_flag_is_set(iface, NET_IF_IPV6) ||
net_if_flag_is_set(iface, NET_IF_IPV6_NO_MLD)) {
return;
}
int idx = net_if_get_by_iface(iface);
if (idx <= 0) {
return;
}
ctx->found_any = true;
if ((*ctx->callback)(static_cast<unsigned int>(idx))) {
ctx->done = true;
}
},
&ctx);
if (!ctx.found_any) {
errno = EADDRNOTAVAIL;
}
return ctx.found_any;
#else
bool found_any = false;
struct netif *netif;
NETIF_FOREACH(netif) {
if (netif->name[0] == 'l' && netif->name[1] == 'o') {
continue;
}
if (!(netif->flags & NETIF_FLAG_UP) || !(netif->flags & NETIF_FLAG_MLD6)) {
continue;
}
found_any = true;
if (callback(netif_get_index(netif))) {
break;
}
}
if (!found_any) {
errno = EADDRNOTAVAIL;
}
return found_any;
#endif
}
bool join_multicast_group(Socket *sock, const char *ip_address, uint32_t *if_index_out) {
if (strchr(ip_address, ':') == nullptr) {
struct in_addr ipv4mc {};
#ifdef USE_ZEPHYR
if (zsock_inet_pton(AF_INET, ip_address, &ipv4mc) != 1) {
#else
if (inet_aton(ip_address, &ipv4mc) == 0) {
#endif
errno = EINVAL;
return false;
}
#ifdef USE_ZEPHYR
// Zephyr's setsockopt(IP_ADD_MEMBERSHIP) requires exactly sizeof(struct ip_mreqn); it has
// no plain "struct ip_mreq" at all (unlike POSIX/lwIP).
struct ip_mreqn imreq {};
imreq.imr_address.s_addr = ESPHOME_INADDR_ANY;
imreq.imr_ifindex = 0;
#else
struct ip_mreq imreq {};
imreq.imr_interface.s_addr = ESPHOME_INADDR_ANY;
#endif
imreq.imr_multiaddr = ipv4mc;
if (sock->setsockopt(IPPROTO_IP, IP_ADD_MEMBERSHIP, &imreq, sizeof(imreq)) < 0) {
return false;
}
if (if_index_out != nullptr) {
*if_index_out = 0;
}
return true;
}
#if USE_NETWORK_IPV6
struct ipv6_mreq imreq6 {};
#ifdef USE_SOCKET_IMPL_BSD_SOCKETS
#ifdef USE_ZEPHYR
if (zsock_inet_pton(AF_INET6, ip_address, &imreq6.ipv6mr_multiaddr) != 1) {
#else
if (inet_pton(AF_INET6, ip_address, &imreq6.ipv6mr_multiaddr) != 1) {
#endif
errno = EINVAL;
return false;
}
#else
ip6_addr_t ip6;
if (!inet6_aton(ip_address, &ip6)) {
errno = EINVAL;
return false;
}
memcpy(imreq6.ipv6mr_multiaddr.un.u32_addr, ip6.addr, sizeof(ip6.addr));
#endif
// POSIX: interface=0 fails for link-local multicast (ff02::) and may select loopback for
// other scopes. LwIP: NETIF_NO_INDEX=0 is always rejected.
bool joined = false;
foreach_eligible_ipv6_if([&](unsigned int idx) {
#ifdef USE_ZEPHYR
// This Zephyr fork's struct ipv6_mreq has no "ipv6mr_interface" (POSIX name) and no
// IPV6_JOIN_GROUP alias -- only the RFC 3678 legacy ipv6mr_ifindex/IPV6_ADD_MEMBERSHIP names.
imreq6.ipv6mr_ifindex = idx;
if (sock->setsockopt(IPPROTO_IPV6, IPV6_ADD_MEMBERSHIP, &imreq6, sizeof(imreq6)) == 0) {
#else
imreq6.ipv6mr_interface = idx;
if (sock->setsockopt(IPPROTO_IPV6, IPV6_JOIN_GROUP, &imreq6, sizeof(imreq6)) == 0) {
#endif
joined = true;
return true;
}
return false;
});
if (!joined) {
return false;
}
if (if_index_out != nullptr) {
#ifdef USE_ZEPHYR
*if_index_out = imreq6.ipv6mr_ifindex;
#else
*if_index_out = imreq6.ipv6mr_interface;
#endif
}
return true;
#else
errno = EINVAL;
return false;
#endif // USE_NETWORK_IPV6
}
#if USE_NETWORK_IPV6
bool set_ipv6_multicast_if(Socket *sock, uint32_t if_index_in) {
#if defined(USE_ZEPHYR) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS)
// Upstream lwIP (Zephyr's fork, LibreTiny) has no IPV6_MULTICAST_IF; outgoing
// multicast follows the default netif. Zephyr gains ZSOCK_IPV6_MULTICAST_IF in NCS 3.4.0.
(void) sock;
(void) if_index_in;
return true;
#else
uint32_t ifindex = if_index_in;
if (ifindex == 0) {
foreach_eligible_ipv6_if([&](unsigned int idx) {
ifindex = idx;
return true;
});
if (ifindex == 0) {
return false;
}
}
return sock->setsockopt(IPPROTO_IPV6, IPV6_MULTICAST_IF, &ifindex, sizeof(ifindex)) == 0;
#endif
}
#endif // USE_NETWORK_IPV6
#endif // USE_SOCKET_IMPL_BSD_SOCKETS || USE_SOCKET_IMPL_LWIP_SOCKETS
socklen_t set_sockaddr(struct sockaddr *addr, socklen_t addrlen, const char *ip_address, uint16_t port) {
#if USE_NETWORK_IPV6
if (strchr(ip_address, ':') != nullptr) {
+22
View File
@@ -94,6 +94,7 @@ inline bool Socket::ready() const {
/// Create a socket of the given domain, type and protocol.
std::unique_ptr<Socket> socket(int domain, int type, int protocol);
/// Create a socket in the newest available IP domain (IPv6 or IPv4) of the given type and protocol.
/// Not registered with the main loop's select() - use socket_ip_loop_monitored() if you need that.
std::unique_ptr<Socket> socket_ip(int type, int protocol);
/// Create a socket and monitor it for data in the main loop.
@@ -149,6 +150,27 @@ inline socklen_t set_sockaddr(struct sockaddr *addr, socklen_t addrlen, const st
/// Set a sockaddr to the any address and specified port for the IP version used by socket_ip().
socklen_t set_sockaddr_any(struct sockaddr *addr, socklen_t addrlen, uint16_t port);
#if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS)
/// Join a multicast group on the given socket.
/// @param sock The socket to join on
/// @param ip_address Null-terminated multicast address string (IPv4 or IPv6)
/// @param if_index_out If non-null, receives the interface index used (IPv4 always writes 0). For
/// link-local IPv6 groups, the caller must also copy this into sin6_scope_id
/// before bind() -- POSIX requires it, LwIP ignores it.
/// @return true on success, false on failure (errno set)
bool join_multicast_group(Socket *sock, const char *ip_address, uint32_t *if_index_out = nullptr);
#if USE_NETWORK_IPV6
/// Configure the outgoing IPv6 multicast interface on the given socket.
/// @param sock The socket to configure
/// @param if_index_in Non-zero: use this interface index directly. Zero (default): probe for the first eligible
/// interface via foreach_eligible_ipv6_if(). On single-interface devices (typical for ESP32/ESP8266) this is always
/// correct. On multi-homed hosts the "first eligible" interface is arbitrary; pass the desired index explicitly.
/// @return true on success, false on failure (errno set). A no-op returning true on LWIP_SOCKETS and Zephyr.
bool set_ipv6_multicast_if(Socket *sock, uint32_t if_index_in = 0);
#endif // USE_NETWORK_IPV6
#endif // USE_SOCKET_IMPL_BSD_SOCKETS || USE_SOCKET_IMPL_LWIP_SOCKETS
/// Poll a connect() that returned EINPROGRESS. On error, err_out is SO_ERROR (or
/// errno) on fd implementations and the failure the callbacks recorded on raw lwip.
#ifdef USE_SOCKET_IMPL_LWIP_TCP
@@ -0,0 +1,5 @@
substitutions:
network_enable_ipv6: "true"
packages:
socket: !include common.yaml
@@ -0,0 +1,96 @@
#include <gtest/gtest.h>
#include <cerrno>
#include "esphome/components/socket/socket.h"
#ifdef USE_HOST
namespace esphome::socket::testing {
// =========================================================================
// join_multicast_group — EINVAL on malformed input
// =========================================================================
TEST(JoinMulticastGroup, MalformedIPv4YieldsEINVAL) {
auto sock = esphome::socket::socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);
ASSERT_NE(sock, nullptr);
errno = 0;
EXPECT_FALSE(join_multicast_group(sock.get(), "not-an-ip"));
EXPECT_EQ(errno, EINVAL);
}
TEST(JoinMulticastGroup, MalformedIPv6YieldsEINVAL) {
// ':' present but parse fails — EINVAL regardless of USE_NETWORK_IPV6
auto sock = esphome::socket::socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);
ASSERT_NE(sock, nullptr);
errno = 0;
EXPECT_FALSE(join_multicast_group(sock.get(), "gg::not-ipv6"));
EXPECT_EQ(errno, EINVAL);
}
// =========================================================================
// join_multicast_group — IPv4 IP_ADD_MEMBERSHIP
// =========================================================================
TEST(JoinMulticastGroup, IPv4JoinSetsIfIndexToZero) {
auto sock = esphome::socket::socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);
ASSERT_NE(sock, nullptr);
uint32_t if_index = 99;
// A host with no multicast route (e.g. an isolated container) cannot join
if (join_multicast_group(sock.get(), "239.0.60.53", &if_index)) {
EXPECT_EQ(if_index, 0u);
} else {
EXPECT_EQ(if_index, 99u);
}
}
TEST(JoinMulticastGroup, IPv4JoinNullIfIndexOut) {
auto sock = esphome::socket::socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);
ASSERT_NE(sock, nullptr);
errno = 0;
EXPECT_TRUE(join_multicast_group(sock.get(), "239.0.60.53", nullptr) || errno != 0);
}
// =========================================================================
// join_multicast_group — IPv6 and set_ipv6_multicast_if
// =========================================================================
#if USE_NETWORK_IPV6
TEST(JoinMulticastGroup, IPv6JoinSucceedsOrNoInterface) {
auto sock = esphome::socket::socket(AF_INET6, SOCK_DGRAM, IPPROTO_UDP);
ASSERT_NE(sock, nullptr);
uint32_t if_index = 0;
bool ok = join_multicast_group(sock.get(), "ff12::cafe", &if_index);
if (ok) {
EXPECT_GT(if_index, 0u);
} else {
EXPECT_EQ(errno, EADDRNOTAVAIL);
}
}
TEST(SetIPv6MulticastIf, ProbeWithZeroIndexSucceedsOrNoInterface) {
auto sock = esphome::socket::socket(AF_INET6, SOCK_DGRAM, IPPROTO_UDP);
ASSERT_NE(sock, nullptr);
errno = 0;
EXPECT_TRUE(set_ipv6_multicast_if(sock.get(), 0) || errno == EADDRNOTAVAIL);
}
TEST(SetIPv6MulticastIf, ExplicitIndexFromJoinSucceeds) {
auto sock_join = esphome::socket::socket(AF_INET6, SOCK_DGRAM, IPPROTO_UDP);
auto sock_send = esphome::socket::socket(AF_INET6, SOCK_DGRAM, IPPROTO_UDP);
ASSERT_NE(sock_join, nullptr);
ASSERT_NE(sock_send, nullptr);
uint32_t if_index = 0;
bool joined = join_multicast_group(sock_join.get(), "ff12::cafe", &if_index);
if (joined && if_index != 0) {
EXPECT_TRUE(set_ipv6_multicast_if(sock_send.get(), if_index));
}
}
#endif // USE_NETWORK_IPV6
} // namespace esphome::socket::testing
#endif // USE_HOST