diff --git a/esphome/components/socket/socket.cpp b/esphome/components/socket/socket.cpp index f6d1daa1240..89002cdd19e 100644 --- a/esphome/components/socket/socket.cpp +++ b/esphome/components/socket/socket.cpp @@ -11,6 +11,16 @@ #ifdef USE_HOST #include "esphome/core/wake.h" #endif +#if defined(USE_HOST) +#include +#include +#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 +#endif +#else +#include "lwip/netif.h" +#endif namespace esphome::socket { @@ -139,6 +149,203 @@ std::unique_ptr 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 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(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(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) { diff --git a/esphome/components/socket/socket.h b/esphome/components/socket/socket.h index 9d7dedbbbf9..d1a70c5aa84 100644 --- a/esphome/components/socket/socket.h +++ b/esphome/components/socket/socket.h @@ -94,6 +94,7 @@ inline bool Socket::ready() const { /// Create a socket of the given domain, type and protocol. std::unique_ptr 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_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 diff --git a/tests/components/socket/test-ipv6.bk72xx-ard.yaml b/tests/components/socket/test-ipv6.bk72xx-ard.yaml new file mode 100644 index 00000000000..6075623b54a --- /dev/null +++ b/tests/components/socket/test-ipv6.bk72xx-ard.yaml @@ -0,0 +1,5 @@ +substitutions: + network_enable_ipv6: "true" + +packages: + socket: !include common.yaml diff --git a/tests/components/socket/test_multicast_host.cpp b/tests/components/socket/test_multicast_host.cpp new file mode 100644 index 00000000000..31fe286a865 --- /dev/null +++ b/tests/components/socket/test_multicast_host.cpp @@ -0,0 +1,96 @@ +#include + +#include + +#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