From fa0bff337416d0de186b562399ebd22f00f0e1a3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 9 Mar 2026 23:20:05 -1000 Subject: [PATCH 01/20] [socket] Add lwip raw UDP socket implementation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add native UDP support to the lwip raw TCP socket layer used by ESP8266 and RP2040, eliminating the need for Arduino WiFiUDP fallback. Two new classes: - LWIPRawUDPImpl: send-only UDP (8 bytes overhead) - LWIPRawUDPRecvImpl: send+recv with fixed-size ring buffer (no heap allocation in recv callback) Factory functions: socket_udp(), socket_udp_recv(), socket_ip_udp(), socket_ip_udp_recv() with UDPSocket/UDPRecvSocket type aliases. Additive only — no consumer migration in this PR. --- esphome/components/socket/headers.h | 10 + .../components/socket/lwip_raw_tcp_impl.cpp | 448 ++++++++++++++++-- esphome/components/socket/lwip_raw_tcp_impl.h | 86 ++++ esphome/components/socket/socket.cpp | 26 + esphome/components/socket/socket.h | 19 +- 5 files changed, 549 insertions(+), 40 deletions(-) diff --git a/esphome/components/socket/headers.h b/esphome/components/socket/headers.h index 16e4d23d3b..9ee3873c33 100644 --- a/esphome/components/socket/headers.h +++ b/esphome/components/socket/headers.h @@ -20,6 +20,16 @@ #define IPPROTO_IP 0 #define IPPROTO_TCP 6 +#define IPPROTO_UDP 17 + +#define IP_ADD_MEMBERSHIP 3 +#define IP_DROP_MEMBERSHIP 4 + +// NOLINTNEXTLINE(readability-identifier-naming) +struct ip_mreq { + struct in_addr imr_multiaddr; + struct in_addr imr_interface; +}; #if LWIP_IPV6 #define AF_INET6 10 diff --git a/esphome/components/socket/lwip_raw_tcp_impl.cpp b/esphome/components/socket/lwip_raw_tcp_impl.cpp index 445a57809d..5dd8307556 100644 --- a/esphome/components/socket/lwip_raw_tcp_impl.cpp +++ b/esphome/components/socket/lwip_raw_tcp_impl.cpp @@ -9,6 +9,10 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#include "lwip/igmp.h" +#include "lwip/pbuf.h" +#include "lwip/udp.h" + #ifdef USE_ESP8266 #include // For esp_schedule() #elif defined(USE_RP2040) @@ -120,6 +124,48 @@ static const char *const TAG = "socket.lwip"; #define LWIP_LOG(msg, ...) #endif +// ---- Shared helpers ---- + +/// Convert lwip ip_addr_t + host-order port to sockaddr, based on the socket's address family. +/// Shared by both TCP (LWIPRawCommon) and UDP (LWIPRawUDPImpl) implementations. +static int lwip_ip_to_sockaddr(sa_family_t family, const ip_addr_t *ip, uint16_t port_host, struct sockaddr *name, + socklen_t *addrlen) { + if (family == AF_INET) { + if (*addrlen < sizeof(struct sockaddr_in)) { + errno = EINVAL; + return -1; + } + auto *addr = reinterpret_cast(name); + addr->sin_family = AF_INET; + *addrlen = addr->sin_len = sizeof(struct sockaddr_in); + addr->sin_port = htons(port_host); + inet_addr_from_ip4addr(&addr->sin_addr, ip_2_ip4(ip)); + return 0; + } +#if LWIP_IPV6 + if (family == AF_INET6) { + if (*addrlen < sizeof(struct sockaddr_in6)) { + errno = EINVAL; + return -1; + } + auto *addr = reinterpret_cast(name); + addr->sin6_family = AF_INET6; + *addrlen = addr->sin6_len = sizeof(struct sockaddr_in6); + addr->sin6_port = htons(port_host); + // AF_INET6 sockets may receive IPv4 packets; convert to IPv4-mapped IPv6. + if (IP_IS_V4(ip)) { + ip_addr_t mapped; + ip4_2_ipv4_mapped_ipv6(ip_2_ip6(&mapped), ip_2_ip4(ip)); + inet6_addr_from_ip6addr(&addr->sin6_addr, ip_2_ip6(&mapped)); + } else { + inet6_addr_from_ip6addr(&addr->sin6_addr, ip_2_ip6(ip)); + } + return 0; + } +#endif + return -1; +} + // ---- LWIPRawCommon methods ---- LWIPRawCommon::~LWIPRawCommon() { @@ -346,43 +392,8 @@ int LWIPRawCommon::setsockopt(int level, int optname, const void *optval, sockle } int LWIPRawCommon::ip2sockaddr_(ip_addr_t *ip, uint16_t port, struct sockaddr *name, socklen_t *addrlen) { - if (this->family_ == AF_INET) { - if (*addrlen < sizeof(struct sockaddr_in)) { - errno = EINVAL; - return -1; - } - - struct sockaddr_in *addr = reinterpret_cast(name); - addr->sin_family = AF_INET; - *addrlen = addr->sin_len = sizeof(struct sockaddr_in); - addr->sin_port = port; - inet_addr_from_ip4addr(&addr->sin_addr, ip_2_ip4(ip)); - return 0; - } -#if LWIP_IPV6 - else if (this->family_ == AF_INET6) { - if (*addrlen < sizeof(struct sockaddr_in6)) { - errno = EINVAL; - return -1; - } - - struct sockaddr_in6 *addr = reinterpret_cast(name); - addr->sin6_family = AF_INET6; - *addrlen = addr->sin6_len = sizeof(struct sockaddr_in6); - addr->sin6_port = port; - - // AF_INET6 sockets are bound to IPv4 as well, so we may encounter IPv4 addresses that must be converted to IPv6. - if (IP_IS_V4(ip)) { - ip_addr_t mapped; - ip4_2_ipv4_mapped_ipv6(ip_2_ip6(&mapped), ip_2_ip4(ip)); - inet6_addr_from_ip6addr(&addr->sin6_addr, ip_2_ip6(&mapped)); - } else { - inet6_addr_from_ip6addr(&addr->sin6_addr, ip_2_ip6(ip)); - } - return 0; - } -#endif - return -1; + // TCP pcb stores port in network byte order; convert to host order for the shared helper + return lwip_ip_to_sockaddr(this->family_, ip, ntohs(port), name, addrlen); } // ---- LWIPRawImpl methods ---- @@ -726,11 +737,350 @@ err_t LWIPRawListenImpl::accept_fn_(struct tcp_pcb *newpcb, err_t err) { return ERR_OK; } +// ---- LWIPRawUDPImpl (send-only) methods ---- + +LWIPRawUDPImpl::LWIPRawUDPImpl(sa_family_t family) : family_(family) { +#if LWIP_IPV6 + this->pcb_ = udp_new_ip_type(family == AF_INET6 ? IPADDR_TYPE_ANY : IPADDR_TYPE_V4); +#else + this->pcb_ = udp_new(); +#endif +} + +LWIPRawUDPImpl::~LWIPRawUDPImpl() { + if (this->pcb_ != nullptr) { + udp_remove(this->pcb_); + this->pcb_ = nullptr; + } +} + +int LWIPRawUDPImpl::bind_internal_(const struct sockaddr *name, socklen_t addrlen) { + if (this->pcb_ == nullptr) { + errno = EBADF; + return -1; + } + if (name == nullptr) { + errno = EINVAL; + return -1; + } + ip_addr_t ip; + uint16_t port; + if (!sockaddr_to_lwip(name, addrlen, &ip, &port)) { + errno = EINVAL; + return -1; + } +#if LWIP_IPV6 + // For bind, use IPADDR_TYPE_ANY on IPv6 sockets to accept both IPv4 and IPv6 + // packets (dual-stack). sockaddr_to_lwip uses IPADDR_TYPE_V6 which is correct + // for sendto destinations but too restrictive for bind. + if (this->family_ == AF_INET6) { + ip.type = IPADDR_TYPE_ANY; + } +#endif + err_t err = udp_bind(this->pcb_, &ip, port); + if (err == ERR_USE) { + errno = EADDRINUSE; + return -1; + } + if (err == ERR_VAL) { + errno = EINVAL; + return -1; + } + if (err != ERR_OK) { + errno = EIO; + return -1; + } + return 0; +} + +int LWIPRawUDPImpl::bind(const struct sockaddr *name, socklen_t addrlen) { return this->bind_internal_(name, addrlen); } + +int LWIPRawUDPImpl::close() { + if (this->pcb_ == nullptr) { + errno = EBADF; + return -1; + } + udp_remove(this->pcb_); + this->pcb_ = nullptr; + return 0; +} + +bool LWIPRawUDPImpl::sockaddr_to_lwip(const struct sockaddr *addr, socklen_t addrlen, ip_addr_t *ip, uint16_t *port) { + if (addrlen < sizeof(sa_family_t)) + return false; +#if LWIP_IPV6 + if (addr->sa_family == AF_INET) { + if (addrlen < sizeof(sockaddr_in)) + return false; + auto *addr4 = reinterpret_cast(addr); + *port = ntohs(addr4->sin_port); + ip->type = IPADDR_TYPE_V4; + ip->u_addr.ip4.addr = addr4->sin_addr.s_addr; + return true; + } + if (addr->sa_family == AF_INET6) { + if (addrlen < sizeof(sockaddr_in6)) + return false; + auto *addr6 = reinterpret_cast(addr); + *port = ntohs(addr6->sin6_port); + ip->type = IPADDR_TYPE_V6; + memcpy(&ip->u_addr.ip6.addr, &addr6->sin6_addr.un.u8_addr, 16); + return true; + } +#else + if (addr->sa_family == AF_INET) { + if (addrlen < sizeof(sockaddr_in)) + return false; + auto *addr4 = reinterpret_cast(addr); + *port = ntohs(addr4->sin_port); + ip->addr = addr4->sin_addr.s_addr; + return true; + } +#endif + return false; +} + +int LWIPRawUDPImpl::ip2sockaddr_(const ip_addr_t *ip, uint16_t port, struct sockaddr *name, socklen_t *addrlen) { + // UDP recv callback provides port in host byte order + return lwip_ip_to_sockaddr(this->family_, ip, port, name, addrlen); +} + +ssize_t LWIPRawUDPImpl::sendto(const void *buf, size_t len, int flags, const struct sockaddr *dest_addr, + socklen_t addrlen) { + if (this->pcb_ == nullptr) { + errno = EBADF; + return -1; + } + if (buf == nullptr || dest_addr == nullptr) { + errno = EINVAL; + return -1; + } + + // pbuf_alloc takes u16_t length; reject oversized packets + if (len > UINT16_MAX) { + errno = EMSGSIZE; + return -1; + } + + ip_addr_t dst_ip; + uint16_t dst_port; + if (!sockaddr_to_lwip(dest_addr, addrlen, &dst_ip, &dst_port)) { + errno = EINVAL; + return -1; + } + + // Allocate pbuf and copy data + struct pbuf *pb = pbuf_alloc(PBUF_TRANSPORT, (uint16_t) len, PBUF_RAM); + if (pb == nullptr) { + errno = ENOMEM; + return -1; + } + memcpy(pb->payload, buf, len); + + err_t err = udp_sendto(this->pcb_, pb, &dst_ip, dst_port); + pbuf_free(pb); + + if (err != ERR_OK) { + errno = err == ERR_MEM ? ENOMEM : EIO; + return -1; + } + return (ssize_t) len; +} + +int LWIPRawUDPImpl::setsockopt(int level, int optname, const void *optval, socklen_t optlen) { + if (this->pcb_ == nullptr) { + errno = EBADF; + return -1; + } + if (level == SOL_SOCKET && optname == SO_REUSEADDR) { + // lwip raw UDP doesn't enforce port exclusivity the same way, + // but we accept this silently for compatibility + return 0; + } + if (level == SOL_SOCKET && optname == SO_BROADCAST) { + if (optval == nullptr || optlen < sizeof(int)) { + errno = EINVAL; + return -1; + } + int val = *reinterpret_cast(optval); + if (val) { + ip_set_option(this->pcb_, SOF_BROADCAST); + } else { + ip_reset_option(this->pcb_, SOF_BROADCAST); + } + return 0; + } + if (level == IPPROTO_IP && optname == IP_ADD_MEMBERSHIP) { + if (optval == nullptr || optlen < sizeof(struct ip_mreq)) { + errno = EINVAL; + return -1; + } + auto *mreq = reinterpret_cast(optval); + ip4_addr_t multiaddr; + multiaddr.addr = mreq->imr_multiaddr.s_addr; + ip4_addr_t ifaddr; + ifaddr.addr = mreq->imr_interface.s_addr; + err_t err = igmp_joingroup(&ifaddr, &multiaddr); + if (err != ERR_OK) { + errno = EIO; + return -1; + } + return 0; + } + if (level == IPPROTO_IP && optname == IP_DROP_MEMBERSHIP) { + if (optval == nullptr || optlen < sizeof(struct ip_mreq)) { + errno = EINVAL; + return -1; + } + auto *mreq = reinterpret_cast(optval); + ip4_addr_t multiaddr; + multiaddr.addr = mreq->imr_multiaddr.s_addr; + ip4_addr_t ifaddr; + ifaddr.addr = mreq->imr_interface.s_addr; + err_t err = igmp_leavegroup(&ifaddr, &multiaddr); + if (err != ERR_OK) { + errno = EIO; + return -1; + } + return 0; + } + errno = ENOPROTOOPT; + return -1; +} + +int LWIPRawUDPImpl::getsockopt(int level, int optname, void *optval, socklen_t *optlen) { + if (this->pcb_ == nullptr) { + errno = EBADF; + return -1; + } + if (level == SOL_SOCKET && optname == SO_REUSEADDR) { + if (optval == nullptr || optlen == nullptr || *optlen < sizeof(int)) { + errno = EINVAL; + return -1; + } + *reinterpret_cast(optval) = 1; + *optlen = sizeof(int); + return 0; + } + errno = ENOPROTOOPT; + return -1; +} + +int LWIPRawUDPImpl::setblocking(bool blocking) { + if (blocking) { + // blocking operation not supported on raw lwip + errno = EINVAL; + return -1; + } + return 0; +} + +// ---- LWIPRawUDPRecvImpl methods ---- + +LWIPRawUDPRecvImpl::~LWIPRawUDPRecvImpl() { + // Flush rx queue and unregister callback before base destructor removes pcb + if (this->pcb_ != nullptr) + this->close(); +} + +int LWIPRawUDPRecvImpl::close() { + // Unregister recv callback before removing pcb + if (this->pcb_ != nullptr) { + udp_recv(this->pcb_, nullptr, nullptr); + } + // Flush any queued rx packets + while (this->rx_read_idx_ != this->rx_write_idx_) { + auto &pkt = this->rx_queue_[this->rx_read_idx_]; + if (pkt.pb != nullptr) { + pbuf_free(pkt.pb); + pkt.pb = nullptr; + } + this->rx_read_idx_ = (this->rx_read_idx_ + 1) & UDP_RX_MASK; + } + // close() returns EBADF if already closed, which is fine from destructor + return LWIPRawUDPImpl::close(); +} + +int LWIPRawUDPRecvImpl::bind(const struct sockaddr *name, socklen_t addrlen) { + int ret = this->bind_internal_(name, addrlen); + if (ret != 0) + return ret; + // Register recv callback now that we're bound and ready to receive + udp_recv(this->pcb_, LWIPRawUDPRecvImpl::s_recv_fn, this); + return 0; +} + +ssize_t LWIPRawUDPRecvImpl::read(void *buf, size_t len) { return this->recvfrom(buf, len, nullptr, nullptr); } + +ssize_t LWIPRawUDPRecvImpl::recvfrom(void *buf, size_t len, struct sockaddr *src_addr, socklen_t *addrlen) { + if (this->pcb_ == nullptr) { + errno = EBADF; + return -1; + } + if (buf == nullptr && len > 0) { + errno = EINVAL; + return -1; + } + if (this->rx_read_idx_ == this->rx_write_idx_) { + errno = EWOULDBLOCK; + return -1; + } + + auto &pkt = this->rx_queue_[this->rx_read_idx_]; + size_t pkt_len = pkt.pb->tot_len; + size_t copy_len = std::min(len, pkt_len); + + // Copy data from pbuf chain + pbuf_copy_partial(pkt.pb, buf, copy_len, 0); + + // Fill in source address if requested + if (src_addr != nullptr && addrlen != nullptr) { + this->ip2sockaddr_(&pkt.src_addr, pkt.src_port, src_addr, addrlen); + } + + // Free the pbuf and advance the read pointer — must be last, + // as this publishes the slot to the producer (recv callback). + pbuf_free(pkt.pb); + pkt.pb = nullptr; + this->rx_read_idx_ = (this->rx_read_idx_ + 1) & UDP_RX_MASK; + + return (ssize_t) copy_len; +} + +void LWIPRawUDPRecvImpl::s_recv_fn(void *arg, struct udp_pcb *pcb, struct pbuf *p, const ip_addr_t *addr, u16_t port) { + auto *self = reinterpret_cast(arg); + self->recv_fn_(p, addr, port); +} + +void LWIPRawUDPRecvImpl::recv_fn_(struct pbuf *p, const ip_addr_t *addr, u16_t port) { + if (p == nullptr) + return; + + // Check if queue is full (next write position would collide with read position) + uint8_t next_write = (this->rx_write_idx_ + 1) & UDP_RX_MASK; + if (next_write == this->rx_read_idx_) { + // Drop packet — queue full + pbuf_free(p); + return; + } + + // Enqueue the packet — write data first, then publish by advancing write index. + auto &slot = this->rx_queue_[this->rx_write_idx_]; + slot.pb = p; + slot.src_addr = *addr; + slot.src_port = port; + this->rx_write_idx_ = next_write; + +#if defined(USE_ESP8266) || defined(USE_RP2040) + socket_wake(); +#endif +} + // ---- Factory functions ---- std::unique_ptr socket(int domain, int type, int protocol) { if (type != SOCK_STREAM) { - ESP_LOGE(TAG, "UDP sockets not supported on this platform, use WiFiUDP"); + ESP_LOGE(TAG, "Use socket_udp() for UDP sockets on this platform"); errno = EPROTOTYPE; return nullptr; } @@ -747,9 +1097,29 @@ std::unique_ptr socket_loop_monitored(int domain, int type, int protocol return socket(domain, type, protocol); } +std::unique_ptr socket_udp(int domain, int protocol) { + (void) protocol; // Raw lwip UDP ignores protocol; kept for API compatibility + auto sock = make_unique((sa_family_t) domain); + if (!sock->is_valid()) { + errno = ENOMEM; + return nullptr; + } + return sock; +} + +std::unique_ptr socket_udp_recv(int domain, int protocol) { + (void) protocol; // Raw lwip UDP ignores protocol; kept for API compatibility + auto sock = make_unique((sa_family_t) domain); + if (!sock->is_valid()) { + errno = ENOMEM; + return nullptr; + } + return sock; +} + std::unique_ptr socket_listen(int domain, int type, int protocol) { if (type != SOCK_STREAM) { - ESP_LOGE(TAG, "UDP sockets not supported on this platform, use WiFiUDP"); + ESP_LOGE(TAG, "Use socket_udp() for UDP sockets on this platform"); errno = EPROTOTYPE; return nullptr; } diff --git a/esphome/components/socket/lwip_raw_tcp_impl.h b/esphome/components/socket/lwip_raw_tcp_impl.h index c171e0537f..b0c0d7b827 100644 --- a/esphome/components/socket/lwip_raw_tcp_impl.h +++ b/esphome/components/socket/lwip_raw_tcp_impl.h @@ -15,6 +15,7 @@ #include "lwip/netif.h" #include "lwip/opt.h" #include "lwip/tcp.h" +#include "lwip/udp.h" namespace esphome::socket { @@ -195,6 +196,91 @@ class LWIPRawListenImpl : public LWIPRawCommon { uint8_t accepted_socket_count_ = 0; // Number of sockets currently in queue }; +/// Send-only UDP socket implementation for LWIP raw API. +/// Non-virtual, concrete type. Uses lwip/udp.h raw API. +/// No receive capability — use LWIPRawUDPRecvImpl for sockets that need to receive. +class LWIPRawUDPImpl { + public: + LWIPRawUDPImpl(sa_family_t family); + ~LWIPRawUDPImpl(); + LWIPRawUDPImpl(const LWIPRawUDPImpl &) = delete; + LWIPRawUDPImpl &operator=(const LWIPRawUDPImpl &) = delete; + + int bind(const struct sockaddr *name, socklen_t addrlen); + int close(); + + /// Send a UDP packet to the specified destination. + ssize_t sendto(const void *buf, size_t len, int flags, const struct sockaddr *dest_addr, socklen_t addrlen); + + int setsockopt(int level, int optname, const void *optval, socklen_t optlen); + int getsockopt(int level, int optname, void *optval, socklen_t *optlen); + + int setblocking(bool blocking); + + bool is_valid() const { return this->pcb_ != nullptr; } + bool ready() const { return false; } + int get_fd() const { return -1; } + + protected: + /// Convert sockaddr to lwip ip_addr_t and port. + static bool sockaddr_to_lwip(const struct sockaddr *addr, socklen_t addrlen, ip_addr_t *ip, uint16_t *port); + /// Convert lwip ip_addr_t and port to sockaddr. + int ip2sockaddr_(const ip_addr_t *ip, uint16_t port, struct sockaddr *name, socklen_t *addrlen); + + /// Shared bind logic — parses sockaddr and calls udp_bind. + int bind_internal_(const struct sockaddr *name, socklen_t addrlen); + + struct udp_pcb *pcb_{nullptr}; + sa_family_t family_{0}; +}; + +/// UDP socket with receive support for LWIP raw API. +/// Extends LWIPRawUDPImpl with a fixed-size ring buffer for incoming packets. +/// The recv callback is registered on bind(). +class LWIPRawUDPRecvImpl : public LWIPRawUDPImpl { + public: + using LWIPRawUDPImpl::LWIPRawUDPImpl; + ~LWIPRawUDPRecvImpl(); + + /// Close the socket, flushing any queued rx packets first. + int close(); + + /// Bind and register the recv callback for incoming packets. + int bind(const struct sockaddr *name, socklen_t addrlen); + + /// Read the next queued packet, discarding source address info. + /// If buf is smaller than the packet, data is silently truncated (returns bytes copied). + ssize_t read(void *buf, size_t len); + /// Read the next queued packet and return the source address. + /// If buf is smaller than the packet, data is silently truncated (returns bytes copied). + ssize_t recvfrom(void *buf, size_t len, struct sockaddr *src_addr, socklen_t *addrlen); + + /// Returns true if there are packets available to read. + bool ready() const { return this->rx_read_idx_ != this->rx_write_idx_; } + + protected: + static void s_recv_fn(void *arg, struct udp_pcb *pcb, struct pbuf *p, const ip_addr_t *addr, u16_t port); + void recv_fn_(struct pbuf *p, const ip_addr_t *addr, u16_t port); + + /// Lock-free SPSC ring buffer for received UDP packets. + /// Producer (recv callback, possibly IRQ context on RP2040) writes rx_write_idx_. + /// Consumer (main loop) writes rx_read_idx_. + /// No shared read-modify-write — safe without locking. + /// One slot is reserved to distinguish full from empty, giving 3 usable slots. + /// No heap allocation in the recv callback — packets are dropped if the queue is full. + static constexpr uint8_t UDP_RX_QUEUE_SIZE = 4; // Must be power of 2 + static constexpr uint8_t UDP_RX_MASK = UDP_RX_QUEUE_SIZE - 1; + static_assert((UDP_RX_QUEUE_SIZE & UDP_RX_MASK) == 0, "UDP_RX_QUEUE_SIZE must be power of 2"); + struct UDPRxPacket { + struct pbuf *pb{nullptr}; + ip_addr_t src_addr{}; + uint16_t src_port{0}; + }; + std::array rx_queue_{}; + volatile uint8_t rx_read_idx_{0}; ///< Written by consumer (main loop), read by producer + volatile uint8_t rx_write_idx_{0}; ///< Written by producer (recv callback), read by consumer +}; + } // namespace esphome::socket #endif // USE_SOCKET_IMPL_LWIP_TCP diff --git a/esphome/components/socket/socket.cpp b/esphome/components/socket/socket.cpp index bfb6ae8e13..d039c5436e 100644 --- a/esphome/components/socket/socket.cpp +++ b/esphome/components/socket/socket.cpp @@ -101,6 +101,32 @@ std::unique_ptr socket_ip_loop_monitored(int type, int protocol) { } #endif +#if !defined(USE_SOCKET_IMPL_LWIP_TCP) +// BSD and LWIP_SOCKETS: UDPSocket == UDPRecvSocket == Socket, so these just delegate. +std::unique_ptr socket_udp(int domain, int protocol) { + return esphome::socket::socket(domain, SOCK_DGRAM, protocol); +} +std::unique_ptr socket_udp_recv(int domain, int protocol) { + return esphome::socket::socket(domain, SOCK_DGRAM, protocol); +} +#endif + +std::unique_ptr socket_ip_udp(int protocol) { +#if USE_NETWORK_IPV6 + return socket_udp(AF_INET6, protocol); +#else + return socket_udp(AF_INET, protocol); +#endif +} + +std::unique_ptr socket_ip_udp_recv(int protocol) { +#if USE_NETWORK_IPV6 + return socket_udp_recv(AF_INET6, protocol); +#else + return socket_udp_recv(AF_INET, protocol); +#endif +} + 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 a21bd64730..c790719816 100644 --- a/esphome/components/socket/socket.h +++ b/esphome/components/socket/socket.h @@ -27,17 +27,24 @@ namespace esphome::socket { // Type aliases — only one implementation is active per build. // Socket is the concrete type for connected sockets. // ListenSocket is the concrete type for listening/server sockets. -// On BSD and LWIP_SOCKETS, both aliases resolve to the same type. +// UDPSocket is the concrete type for UDP sockets. +// On BSD and LWIP_SOCKETS, all aliases resolve to the same type. // On LWIP_TCP, they are different types (no virtual dispatch between them). #ifdef USE_SOCKET_IMPL_BSD_SOCKETS using Socket = BSDSocketImpl; using ListenSocket = BSDSocketImpl; +using UDPSocket = BSDSocketImpl; +using UDPRecvSocket = BSDSocketImpl; #elif defined(USE_SOCKET_IMPL_LWIP_SOCKETS) using Socket = LwIPSocketImpl; using ListenSocket = LwIPSocketImpl; +using UDPSocket = LwIPSocketImpl; +using UDPRecvSocket = LwIPSocketImpl; #elif defined(USE_SOCKET_IMPL_LWIP_TCP) using Socket = LWIPRawImpl; using ListenSocket = LWIPRawListenImpl; +using UDPSocket = LWIPRawUDPImpl; +using UDPRecvSocket = LWIPRawUDPRecvImpl; #endif #ifdef USE_LWIP_FAST_SELECT @@ -68,6 +75,16 @@ 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. std::unique_ptr socket_ip(int type, int protocol); +/// Create a send-only UDP socket of the given domain and protocol. +std::unique_ptr socket_udp(int domain, int protocol); +/// Create a send-only UDP socket in the newest available IP domain. +std::unique_ptr socket_ip_udp(int protocol); + +/// Create a UDP socket with receive support of the given domain and protocol. +std::unique_ptr socket_udp_recv(int domain, int protocol); +/// Create a UDP socket with receive support in the newest available IP domain. +std::unique_ptr socket_ip_udp_recv(int protocol); + /// Create a socket and monitor it for data in the main loop. /// Like socket() but also registers the socket with the Application's select() loop. /// WARNING: These functions are NOT thread-safe. They must only be called from the main loop From 7dea3756e98811e32a929fc99011e2429acd4c2c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 9 Mar 2026 23:56:37 -1000 Subject: [PATCH 02/20] [socket] Address review comments: doc comments and (void) flags - Document port_host byte order convention in lwip_ip_to_sockaddr - Note intentional method hiding in LWIPRawUDPRecvImpl - Note recvfrom truncation differs from POSIX MSG_TRUNC - Add (void) flags in sendto to clarify flags are ignored Co-Authored-By: J. Nick Koston --- esphome/components/socket/lwip_raw_tcp_impl.cpp | 4 ++++ esphome/components/socket/lwip_raw_tcp_impl.h | 7 +++++++ 2 files changed, 11 insertions(+) diff --git a/esphome/components/socket/lwip_raw_tcp_impl.cpp b/esphome/components/socket/lwip_raw_tcp_impl.cpp index 5dd8307556..a885b1add1 100644 --- a/esphome/components/socket/lwip_raw_tcp_impl.cpp +++ b/esphome/components/socket/lwip_raw_tcp_impl.cpp @@ -127,6 +127,9 @@ static const char *const TAG = "socket.lwip"; // ---- Shared helpers ---- /// Convert lwip ip_addr_t + host-order port to sockaddr, based on the socket's address family. +/// @param port_host Port in host byte order. TCP callers must convert from network order first +/// (tcp_pcb stores ports in network byte order); UDP callers can pass directly +/// (lwip udp_recv callback provides port in host byte order). /// Shared by both TCP (LWIPRawCommon) and UDP (LWIPRawUDPImpl) implementations. static int lwip_ip_to_sockaddr(sa_family_t family, const ip_addr_t *ip, uint16_t port_host, struct sockaddr *name, socklen_t *addrlen) { @@ -847,6 +850,7 @@ int LWIPRawUDPImpl::ip2sockaddr_(const ip_addr_t *ip, uint16_t port, struct sock ssize_t LWIPRawUDPImpl::sendto(const void *buf, size_t len, int flags, const struct sockaddr *dest_addr, socklen_t addrlen) { + (void) flags; // Flags (MSG_DONTWAIT, etc.) are ignored; raw lwip is always non-blocking if (this->pcb_ == nullptr) { errno = EBADF; return -1; diff --git a/esphome/components/socket/lwip_raw_tcp_impl.h b/esphome/components/socket/lwip_raw_tcp_impl.h index b0c0d7b827..c39aee7dd4 100644 --- a/esphome/components/socket/lwip_raw_tcp_impl.h +++ b/esphome/components/socket/lwip_raw_tcp_impl.h @@ -237,6 +237,11 @@ class LWIPRawUDPImpl { /// UDP socket with receive support for LWIP raw API. /// Extends LWIPRawUDPImpl with a fixed-size ring buffer for incoming packets. /// The recv callback is registered on bind(). +/// +/// Note: close() and bind() intentionally hide the base class methods to add +/// recv callback registration/cleanup. This is safe because these classes are +/// never used polymorphically (no virtual dispatch) — callers always use the +/// concrete LWIPRawUDPRecvImpl type via the UDPRecvSocket alias. class LWIPRawUDPRecvImpl : public LWIPRawUDPImpl { public: using LWIPRawUDPImpl::LWIPRawUDPImpl; @@ -250,9 +255,11 @@ class LWIPRawUDPRecvImpl : public LWIPRawUDPImpl { /// Read the next queued packet, discarding source address info. /// If buf is smaller than the packet, data is silently truncated (returns bytes copied). + /// Note: unlike POSIX MSG_TRUNC, this does not return the original packet length on truncation. ssize_t read(void *buf, size_t len); /// Read the next queued packet and return the source address. /// If buf is smaller than the packet, data is silently truncated (returns bytes copied). + /// Note: unlike POSIX MSG_TRUNC, this does not return the original packet length on truncation. ssize_t recvfrom(void *buf, size_t len, struct sockaddr *src_addr, socklen_t *addrlen); /// Returns true if there are packets available to read. From 49ba08cec98bb2ac8e95ab67dbc1fe6f67a05542 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 9 Mar 2026 23:20:05 -1000 Subject: [PATCH 03/20] [socket] Add lwip raw UDP socket implementation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add native UDP support to the lwip raw TCP socket layer used by ESP8266 and RP2040, eliminating the need for Arduino WiFiUDP fallback. Two new classes: - LWIPRawUDPImpl: send-only UDP (8 bytes overhead) - LWIPRawUDPRecvImpl: send+recv with fixed-size ring buffer (no heap allocation in recv callback) Factory functions: socket_udp(), socket_udp_recv(), socket_ip_udp(), socket_ip_udp_recv() with UDPSocket/UDPRecvSocket type aliases. Additive only — no consumer migration in this PR. --- esphome/components/socket/headers.h | 10 + .../components/socket/lwip_raw_tcp_impl.cpp | 448 ++++++++++++++++-- esphome/components/socket/lwip_raw_tcp_impl.h | 86 ++++ esphome/components/socket/socket.cpp | 26 + esphome/components/socket/socket.h | 19 +- 5 files changed, 549 insertions(+), 40 deletions(-) diff --git a/esphome/components/socket/headers.h b/esphome/components/socket/headers.h index 16e4d23d3b..9ee3873c33 100644 --- a/esphome/components/socket/headers.h +++ b/esphome/components/socket/headers.h @@ -20,6 +20,16 @@ #define IPPROTO_IP 0 #define IPPROTO_TCP 6 +#define IPPROTO_UDP 17 + +#define IP_ADD_MEMBERSHIP 3 +#define IP_DROP_MEMBERSHIP 4 + +// NOLINTNEXTLINE(readability-identifier-naming) +struct ip_mreq { + struct in_addr imr_multiaddr; + struct in_addr imr_interface; +}; #if LWIP_IPV6 #define AF_INET6 10 diff --git a/esphome/components/socket/lwip_raw_tcp_impl.cpp b/esphome/components/socket/lwip_raw_tcp_impl.cpp index d7fa6a2694..26d62cd298 100644 --- a/esphome/components/socket/lwip_raw_tcp_impl.cpp +++ b/esphome/components/socket/lwip_raw_tcp_impl.cpp @@ -9,6 +9,10 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#include "lwip/igmp.h" +#include "lwip/pbuf.h" +#include "lwip/udp.h" + #ifdef USE_ESP8266 #include // For esp_schedule() #elif defined(USE_RP2040) @@ -138,6 +142,48 @@ static const char *const TAG = "socket.lwip"; #define LWIP_LOG(msg, ...) #endif +// ---- Shared helpers ---- + +/// Convert lwip ip_addr_t + host-order port to sockaddr, based on the socket's address family. +/// Shared by both TCP (LWIPRawCommon) and UDP (LWIPRawUDPImpl) implementations. +static int lwip_ip_to_sockaddr(sa_family_t family, const ip_addr_t *ip, uint16_t port_host, struct sockaddr *name, + socklen_t *addrlen) { + if (family == AF_INET) { + if (*addrlen < sizeof(struct sockaddr_in)) { + errno = EINVAL; + return -1; + } + auto *addr = reinterpret_cast(name); + addr->sin_family = AF_INET; + *addrlen = addr->sin_len = sizeof(struct sockaddr_in); + addr->sin_port = htons(port_host); + inet_addr_from_ip4addr(&addr->sin_addr, ip_2_ip4(ip)); + return 0; + } +#if LWIP_IPV6 + if (family == AF_INET6) { + if (*addrlen < sizeof(struct sockaddr_in6)) { + errno = EINVAL; + return -1; + } + auto *addr = reinterpret_cast(name); + addr->sin6_family = AF_INET6; + *addrlen = addr->sin6_len = sizeof(struct sockaddr_in6); + addr->sin6_port = htons(port_host); + // AF_INET6 sockets may receive IPv4 packets; convert to IPv4-mapped IPv6. + if (IP_IS_V4(ip)) { + ip_addr_t mapped; + ip4_2_ipv4_mapped_ipv6(ip_2_ip6(&mapped), ip_2_ip4(ip)); + inet6_addr_from_ip6addr(&addr->sin6_addr, ip_2_ip6(&mapped)); + } else { + inet6_addr_from_ip6addr(&addr->sin6_addr, ip_2_ip6(ip)); + } + return 0; + } +#endif + return -1; +} + // ---- LWIPRawCommon methods ---- LWIPRawCommon::~LWIPRawCommon() { @@ -372,43 +418,8 @@ int LWIPRawCommon::setsockopt(int level, int optname, const void *optval, sockle } int LWIPRawCommon::ip2sockaddr_(ip_addr_t *ip, uint16_t port, struct sockaddr *name, socklen_t *addrlen) { - if (this->family_ == AF_INET) { - if (*addrlen < sizeof(struct sockaddr_in)) { - errno = EINVAL; - return -1; - } - - struct sockaddr_in *addr = reinterpret_cast(name); - addr->sin_family = AF_INET; - *addrlen = addr->sin_len = sizeof(struct sockaddr_in); - addr->sin_port = port; - inet_addr_from_ip4addr(&addr->sin_addr, ip_2_ip4(ip)); - return 0; - } -#if LWIP_IPV6 - else if (this->family_ == AF_INET6) { - if (*addrlen < sizeof(struct sockaddr_in6)) { - errno = EINVAL; - return -1; - } - - struct sockaddr_in6 *addr = reinterpret_cast(name); - addr->sin6_family = AF_INET6; - *addrlen = addr->sin6_len = sizeof(struct sockaddr_in6); - addr->sin6_port = port; - - // AF_INET6 sockets are bound to IPv4 as well, so we may encounter IPv4 addresses that must be converted to IPv6. - if (IP_IS_V4(ip)) { - ip_addr_t mapped; - ip4_2_ipv4_mapped_ipv6(ip_2_ip6(&mapped), ip_2_ip4(ip)); - inet6_addr_from_ip6addr(&addr->sin6_addr, ip_2_ip6(&mapped)); - } else { - inet6_addr_from_ip6addr(&addr->sin6_addr, ip_2_ip6(ip)); - } - return 0; - } -#endif - return -1; + // TCP pcb stores port in network byte order; convert to host order for the shared helper + return lwip_ip_to_sockaddr(this->family_, ip, ntohs(port), name, addrlen); } // ---- LWIPRawImpl methods ---- @@ -774,11 +785,350 @@ err_t LWIPRawListenImpl::accept_fn_(struct tcp_pcb *newpcb, err_t err) { return ERR_OK; } +// ---- LWIPRawUDPImpl (send-only) methods ---- + +LWIPRawUDPImpl::LWIPRawUDPImpl(sa_family_t family) : family_(family) { +#if LWIP_IPV6 + this->pcb_ = udp_new_ip_type(family == AF_INET6 ? IPADDR_TYPE_ANY : IPADDR_TYPE_V4); +#else + this->pcb_ = udp_new(); +#endif +} + +LWIPRawUDPImpl::~LWIPRawUDPImpl() { + if (this->pcb_ != nullptr) { + udp_remove(this->pcb_); + this->pcb_ = nullptr; + } +} + +int LWIPRawUDPImpl::bind_internal_(const struct sockaddr *name, socklen_t addrlen) { + if (this->pcb_ == nullptr) { + errno = EBADF; + return -1; + } + if (name == nullptr) { + errno = EINVAL; + return -1; + } + ip_addr_t ip; + uint16_t port; + if (!sockaddr_to_lwip(name, addrlen, &ip, &port)) { + errno = EINVAL; + return -1; + } +#if LWIP_IPV6 + // For bind, use IPADDR_TYPE_ANY on IPv6 sockets to accept both IPv4 and IPv6 + // packets (dual-stack). sockaddr_to_lwip uses IPADDR_TYPE_V6 which is correct + // for sendto destinations but too restrictive for bind. + if (this->family_ == AF_INET6) { + ip.type = IPADDR_TYPE_ANY; + } +#endif + err_t err = udp_bind(this->pcb_, &ip, port); + if (err == ERR_USE) { + errno = EADDRINUSE; + return -1; + } + if (err == ERR_VAL) { + errno = EINVAL; + return -1; + } + if (err != ERR_OK) { + errno = EIO; + return -1; + } + return 0; +} + +int LWIPRawUDPImpl::bind(const struct sockaddr *name, socklen_t addrlen) { return this->bind_internal_(name, addrlen); } + +int LWIPRawUDPImpl::close() { + if (this->pcb_ == nullptr) { + errno = EBADF; + return -1; + } + udp_remove(this->pcb_); + this->pcb_ = nullptr; + return 0; +} + +bool LWIPRawUDPImpl::sockaddr_to_lwip(const struct sockaddr *addr, socklen_t addrlen, ip_addr_t *ip, uint16_t *port) { + if (addrlen < sizeof(sa_family_t)) + return false; +#if LWIP_IPV6 + if (addr->sa_family == AF_INET) { + if (addrlen < sizeof(sockaddr_in)) + return false; + auto *addr4 = reinterpret_cast(addr); + *port = ntohs(addr4->sin_port); + ip->type = IPADDR_TYPE_V4; + ip->u_addr.ip4.addr = addr4->sin_addr.s_addr; + return true; + } + if (addr->sa_family == AF_INET6) { + if (addrlen < sizeof(sockaddr_in6)) + return false; + auto *addr6 = reinterpret_cast(addr); + *port = ntohs(addr6->sin6_port); + ip->type = IPADDR_TYPE_V6; + memcpy(&ip->u_addr.ip6.addr, &addr6->sin6_addr.un.u8_addr, 16); + return true; + } +#else + if (addr->sa_family == AF_INET) { + if (addrlen < sizeof(sockaddr_in)) + return false; + auto *addr4 = reinterpret_cast(addr); + *port = ntohs(addr4->sin_port); + ip->addr = addr4->sin_addr.s_addr; + return true; + } +#endif + return false; +} + +int LWIPRawUDPImpl::ip2sockaddr_(const ip_addr_t *ip, uint16_t port, struct sockaddr *name, socklen_t *addrlen) { + // UDP recv callback provides port in host byte order + return lwip_ip_to_sockaddr(this->family_, ip, port, name, addrlen); +} + +ssize_t LWIPRawUDPImpl::sendto(const void *buf, size_t len, int flags, const struct sockaddr *dest_addr, + socklen_t addrlen) { + if (this->pcb_ == nullptr) { + errno = EBADF; + return -1; + } + if (buf == nullptr || dest_addr == nullptr) { + errno = EINVAL; + return -1; + } + + // pbuf_alloc takes u16_t length; reject oversized packets + if (len > UINT16_MAX) { + errno = EMSGSIZE; + return -1; + } + + ip_addr_t dst_ip; + uint16_t dst_port; + if (!sockaddr_to_lwip(dest_addr, addrlen, &dst_ip, &dst_port)) { + errno = EINVAL; + return -1; + } + + // Allocate pbuf and copy data + struct pbuf *pb = pbuf_alloc(PBUF_TRANSPORT, (uint16_t) len, PBUF_RAM); + if (pb == nullptr) { + errno = ENOMEM; + return -1; + } + memcpy(pb->payload, buf, len); + + err_t err = udp_sendto(this->pcb_, pb, &dst_ip, dst_port); + pbuf_free(pb); + + if (err != ERR_OK) { + errno = err == ERR_MEM ? ENOMEM : EIO; + return -1; + } + return (ssize_t) len; +} + +int LWIPRawUDPImpl::setsockopt(int level, int optname, const void *optval, socklen_t optlen) { + if (this->pcb_ == nullptr) { + errno = EBADF; + return -1; + } + if (level == SOL_SOCKET && optname == SO_REUSEADDR) { + // lwip raw UDP doesn't enforce port exclusivity the same way, + // but we accept this silently for compatibility + return 0; + } + if (level == SOL_SOCKET && optname == SO_BROADCAST) { + if (optval == nullptr || optlen < sizeof(int)) { + errno = EINVAL; + return -1; + } + int val = *reinterpret_cast(optval); + if (val) { + ip_set_option(this->pcb_, SOF_BROADCAST); + } else { + ip_reset_option(this->pcb_, SOF_BROADCAST); + } + return 0; + } + if (level == IPPROTO_IP && optname == IP_ADD_MEMBERSHIP) { + if (optval == nullptr || optlen < sizeof(struct ip_mreq)) { + errno = EINVAL; + return -1; + } + auto *mreq = reinterpret_cast(optval); + ip4_addr_t multiaddr; + multiaddr.addr = mreq->imr_multiaddr.s_addr; + ip4_addr_t ifaddr; + ifaddr.addr = mreq->imr_interface.s_addr; + err_t err = igmp_joingroup(&ifaddr, &multiaddr); + if (err != ERR_OK) { + errno = EIO; + return -1; + } + return 0; + } + if (level == IPPROTO_IP && optname == IP_DROP_MEMBERSHIP) { + if (optval == nullptr || optlen < sizeof(struct ip_mreq)) { + errno = EINVAL; + return -1; + } + auto *mreq = reinterpret_cast(optval); + ip4_addr_t multiaddr; + multiaddr.addr = mreq->imr_multiaddr.s_addr; + ip4_addr_t ifaddr; + ifaddr.addr = mreq->imr_interface.s_addr; + err_t err = igmp_leavegroup(&ifaddr, &multiaddr); + if (err != ERR_OK) { + errno = EIO; + return -1; + } + return 0; + } + errno = ENOPROTOOPT; + return -1; +} + +int LWIPRawUDPImpl::getsockopt(int level, int optname, void *optval, socklen_t *optlen) { + if (this->pcb_ == nullptr) { + errno = EBADF; + return -1; + } + if (level == SOL_SOCKET && optname == SO_REUSEADDR) { + if (optval == nullptr || optlen == nullptr || *optlen < sizeof(int)) { + errno = EINVAL; + return -1; + } + *reinterpret_cast(optval) = 1; + *optlen = sizeof(int); + return 0; + } + errno = ENOPROTOOPT; + return -1; +} + +int LWIPRawUDPImpl::setblocking(bool blocking) { + if (blocking) { + // blocking operation not supported on raw lwip + errno = EINVAL; + return -1; + } + return 0; +} + +// ---- LWIPRawUDPRecvImpl methods ---- + +LWIPRawUDPRecvImpl::~LWIPRawUDPRecvImpl() { + // Flush rx queue and unregister callback before base destructor removes pcb + if (this->pcb_ != nullptr) + this->close(); +} + +int LWIPRawUDPRecvImpl::close() { + // Unregister recv callback before removing pcb + if (this->pcb_ != nullptr) { + udp_recv(this->pcb_, nullptr, nullptr); + } + // Flush any queued rx packets + while (this->rx_read_idx_ != this->rx_write_idx_) { + auto &pkt = this->rx_queue_[this->rx_read_idx_]; + if (pkt.pb != nullptr) { + pbuf_free(pkt.pb); + pkt.pb = nullptr; + } + this->rx_read_idx_ = (this->rx_read_idx_ + 1) & UDP_RX_MASK; + } + // close() returns EBADF if already closed, which is fine from destructor + return LWIPRawUDPImpl::close(); +} + +int LWIPRawUDPRecvImpl::bind(const struct sockaddr *name, socklen_t addrlen) { + int ret = this->bind_internal_(name, addrlen); + if (ret != 0) + return ret; + // Register recv callback now that we're bound and ready to receive + udp_recv(this->pcb_, LWIPRawUDPRecvImpl::s_recv_fn, this); + return 0; +} + +ssize_t LWIPRawUDPRecvImpl::read(void *buf, size_t len) { return this->recvfrom(buf, len, nullptr, nullptr); } + +ssize_t LWIPRawUDPRecvImpl::recvfrom(void *buf, size_t len, struct sockaddr *src_addr, socklen_t *addrlen) { + if (this->pcb_ == nullptr) { + errno = EBADF; + return -1; + } + if (buf == nullptr && len > 0) { + errno = EINVAL; + return -1; + } + if (this->rx_read_idx_ == this->rx_write_idx_) { + errno = EWOULDBLOCK; + return -1; + } + + auto &pkt = this->rx_queue_[this->rx_read_idx_]; + size_t pkt_len = pkt.pb->tot_len; + size_t copy_len = std::min(len, pkt_len); + + // Copy data from pbuf chain + pbuf_copy_partial(pkt.pb, buf, copy_len, 0); + + // Fill in source address if requested + if (src_addr != nullptr && addrlen != nullptr) { + this->ip2sockaddr_(&pkt.src_addr, pkt.src_port, src_addr, addrlen); + } + + // Free the pbuf and advance the read pointer — must be last, + // as this publishes the slot to the producer (recv callback). + pbuf_free(pkt.pb); + pkt.pb = nullptr; + this->rx_read_idx_ = (this->rx_read_idx_ + 1) & UDP_RX_MASK; + + return (ssize_t) copy_len; +} + +void LWIPRawUDPRecvImpl::s_recv_fn(void *arg, struct udp_pcb *pcb, struct pbuf *p, const ip_addr_t *addr, u16_t port) { + auto *self = reinterpret_cast(arg); + self->recv_fn_(p, addr, port); +} + +void LWIPRawUDPRecvImpl::recv_fn_(struct pbuf *p, const ip_addr_t *addr, u16_t port) { + if (p == nullptr) + return; + + // Check if queue is full (next write position would collide with read position) + uint8_t next_write = (this->rx_write_idx_ + 1) & UDP_RX_MASK; + if (next_write == this->rx_read_idx_) { + // Drop packet — queue full + pbuf_free(p); + return; + } + + // Enqueue the packet — write data first, then publish by advancing write index. + auto &slot = this->rx_queue_[this->rx_write_idx_]; + slot.pb = p; + slot.src_addr = *addr; + slot.src_port = port; + this->rx_write_idx_ = next_write; + +#if defined(USE_ESP8266) || defined(USE_RP2040) + socket_wake(); +#endif +} + // ---- Factory functions ---- std::unique_ptr socket(int domain, int type, int protocol) { if (type != SOCK_STREAM) { - ESP_LOGE(TAG, "UDP sockets not supported on this platform, use WiFiUDP"); + ESP_LOGE(TAG, "Use socket_udp() for UDP sockets on this platform"); errno = EPROTOTYPE; return nullptr; } @@ -796,9 +1146,29 @@ std::unique_ptr socket_loop_monitored(int domain, int type, int protocol return socket(domain, type, protocol); } +std::unique_ptr socket_udp(int domain, int protocol) { + (void) protocol; // Raw lwip UDP ignores protocol; kept for API compatibility + auto sock = make_unique((sa_family_t) domain); + if (!sock->is_valid()) { + errno = ENOMEM; + return nullptr; + } + return sock; +} + +std::unique_ptr socket_udp_recv(int domain, int protocol) { + (void) protocol; // Raw lwip UDP ignores protocol; kept for API compatibility + auto sock = make_unique((sa_family_t) domain); + if (!sock->is_valid()) { + errno = ENOMEM; + return nullptr; + } + return sock; +} + std::unique_ptr socket_listen(int domain, int type, int protocol) { if (type != SOCK_STREAM) { - ESP_LOGE(TAG, "UDP sockets not supported on this platform, use WiFiUDP"); + ESP_LOGE(TAG, "Use socket_udp() for UDP sockets on this platform"); errno = EPROTOTYPE; return nullptr; } diff --git a/esphome/components/socket/lwip_raw_tcp_impl.h b/esphome/components/socket/lwip_raw_tcp_impl.h index 5b2c11cfe2..79ba82d438 100644 --- a/esphome/components/socket/lwip_raw_tcp_impl.h +++ b/esphome/components/socket/lwip_raw_tcp_impl.h @@ -15,6 +15,7 @@ #include "lwip/netif.h" #include "lwip/opt.h" #include "lwip/tcp.h" +#include "lwip/udp.h" namespace esphome::socket { @@ -201,6 +202,91 @@ class LWIPRawListenImpl : public LWIPRawCommon { uint8_t accepted_socket_count_ = 0; // Number of sockets currently in queue }; +/// Send-only UDP socket implementation for LWIP raw API. +/// Non-virtual, concrete type. Uses lwip/udp.h raw API. +/// No receive capability — use LWIPRawUDPRecvImpl for sockets that need to receive. +class LWIPRawUDPImpl { + public: + LWIPRawUDPImpl(sa_family_t family); + ~LWIPRawUDPImpl(); + LWIPRawUDPImpl(const LWIPRawUDPImpl &) = delete; + LWIPRawUDPImpl &operator=(const LWIPRawUDPImpl &) = delete; + + int bind(const struct sockaddr *name, socklen_t addrlen); + int close(); + + /// Send a UDP packet to the specified destination. + ssize_t sendto(const void *buf, size_t len, int flags, const struct sockaddr *dest_addr, socklen_t addrlen); + + int setsockopt(int level, int optname, const void *optval, socklen_t optlen); + int getsockopt(int level, int optname, void *optval, socklen_t *optlen); + + int setblocking(bool blocking); + + bool is_valid() const { return this->pcb_ != nullptr; } + bool ready() const { return false; } + int get_fd() const { return -1; } + + protected: + /// Convert sockaddr to lwip ip_addr_t and port. + static bool sockaddr_to_lwip(const struct sockaddr *addr, socklen_t addrlen, ip_addr_t *ip, uint16_t *port); + /// Convert lwip ip_addr_t and port to sockaddr. + int ip2sockaddr_(const ip_addr_t *ip, uint16_t port, struct sockaddr *name, socklen_t *addrlen); + + /// Shared bind logic — parses sockaddr and calls udp_bind. + int bind_internal_(const struct sockaddr *name, socklen_t addrlen); + + struct udp_pcb *pcb_{nullptr}; + sa_family_t family_{0}; +}; + +/// UDP socket with receive support for LWIP raw API. +/// Extends LWIPRawUDPImpl with a fixed-size ring buffer for incoming packets. +/// The recv callback is registered on bind(). +class LWIPRawUDPRecvImpl : public LWIPRawUDPImpl { + public: + using LWIPRawUDPImpl::LWIPRawUDPImpl; + ~LWIPRawUDPRecvImpl(); + + /// Close the socket, flushing any queued rx packets first. + int close(); + + /// Bind and register the recv callback for incoming packets. + int bind(const struct sockaddr *name, socklen_t addrlen); + + /// Read the next queued packet, discarding source address info. + /// If buf is smaller than the packet, data is silently truncated (returns bytes copied). + ssize_t read(void *buf, size_t len); + /// Read the next queued packet and return the source address. + /// If buf is smaller than the packet, data is silently truncated (returns bytes copied). + ssize_t recvfrom(void *buf, size_t len, struct sockaddr *src_addr, socklen_t *addrlen); + + /// Returns true if there are packets available to read. + bool ready() const { return this->rx_read_idx_ != this->rx_write_idx_; } + + protected: + static void s_recv_fn(void *arg, struct udp_pcb *pcb, struct pbuf *p, const ip_addr_t *addr, u16_t port); + void recv_fn_(struct pbuf *p, const ip_addr_t *addr, u16_t port); + + /// Lock-free SPSC ring buffer for received UDP packets. + /// Producer (recv callback, possibly IRQ context on RP2040) writes rx_write_idx_. + /// Consumer (main loop) writes rx_read_idx_. + /// No shared read-modify-write — safe without locking. + /// One slot is reserved to distinguish full from empty, giving 3 usable slots. + /// No heap allocation in the recv callback — packets are dropped if the queue is full. + static constexpr uint8_t UDP_RX_QUEUE_SIZE = 4; // Must be power of 2 + static constexpr uint8_t UDP_RX_MASK = UDP_RX_QUEUE_SIZE - 1; + static_assert((UDP_RX_QUEUE_SIZE & UDP_RX_MASK) == 0, "UDP_RX_QUEUE_SIZE must be power of 2"); + struct UDPRxPacket { + struct pbuf *pb{nullptr}; + ip_addr_t src_addr{}; + uint16_t src_port{0}; + }; + std::array rx_queue_{}; + volatile uint8_t rx_read_idx_{0}; ///< Written by consumer (main loop), read by producer + volatile uint8_t rx_write_idx_{0}; ///< Written by producer (recv callback), read by consumer +}; + } // namespace esphome::socket #endif // USE_SOCKET_IMPL_LWIP_TCP diff --git a/esphome/components/socket/socket.cpp b/esphome/components/socket/socket.cpp index bfb6ae8e13..d039c5436e 100644 --- a/esphome/components/socket/socket.cpp +++ b/esphome/components/socket/socket.cpp @@ -101,6 +101,32 @@ std::unique_ptr socket_ip_loop_monitored(int type, int protocol) { } #endif +#if !defined(USE_SOCKET_IMPL_LWIP_TCP) +// BSD and LWIP_SOCKETS: UDPSocket == UDPRecvSocket == Socket, so these just delegate. +std::unique_ptr socket_udp(int domain, int protocol) { + return esphome::socket::socket(domain, SOCK_DGRAM, protocol); +} +std::unique_ptr socket_udp_recv(int domain, int protocol) { + return esphome::socket::socket(domain, SOCK_DGRAM, protocol); +} +#endif + +std::unique_ptr socket_ip_udp(int protocol) { +#if USE_NETWORK_IPV6 + return socket_udp(AF_INET6, protocol); +#else + return socket_udp(AF_INET, protocol); +#endif +} + +std::unique_ptr socket_ip_udp_recv(int protocol) { +#if USE_NETWORK_IPV6 + return socket_udp_recv(AF_INET6, protocol); +#else + return socket_udp_recv(AF_INET, protocol); +#endif +} + 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 a21bd64730..c790719816 100644 --- a/esphome/components/socket/socket.h +++ b/esphome/components/socket/socket.h @@ -27,17 +27,24 @@ namespace esphome::socket { // Type aliases — only one implementation is active per build. // Socket is the concrete type for connected sockets. // ListenSocket is the concrete type for listening/server sockets. -// On BSD and LWIP_SOCKETS, both aliases resolve to the same type. +// UDPSocket is the concrete type for UDP sockets. +// On BSD and LWIP_SOCKETS, all aliases resolve to the same type. // On LWIP_TCP, they are different types (no virtual dispatch between them). #ifdef USE_SOCKET_IMPL_BSD_SOCKETS using Socket = BSDSocketImpl; using ListenSocket = BSDSocketImpl; +using UDPSocket = BSDSocketImpl; +using UDPRecvSocket = BSDSocketImpl; #elif defined(USE_SOCKET_IMPL_LWIP_SOCKETS) using Socket = LwIPSocketImpl; using ListenSocket = LwIPSocketImpl; +using UDPSocket = LwIPSocketImpl; +using UDPRecvSocket = LwIPSocketImpl; #elif defined(USE_SOCKET_IMPL_LWIP_TCP) using Socket = LWIPRawImpl; using ListenSocket = LWIPRawListenImpl; +using UDPSocket = LWIPRawUDPImpl; +using UDPRecvSocket = LWIPRawUDPRecvImpl; #endif #ifdef USE_LWIP_FAST_SELECT @@ -68,6 +75,16 @@ 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. std::unique_ptr socket_ip(int type, int protocol); +/// Create a send-only UDP socket of the given domain and protocol. +std::unique_ptr socket_udp(int domain, int protocol); +/// Create a send-only UDP socket in the newest available IP domain. +std::unique_ptr socket_ip_udp(int protocol); + +/// Create a UDP socket with receive support of the given domain and protocol. +std::unique_ptr socket_udp_recv(int domain, int protocol); +/// Create a UDP socket with receive support in the newest available IP domain. +std::unique_ptr socket_ip_udp_recv(int protocol); + /// Create a socket and monitor it for data in the main loop. /// Like socket() but also registers the socket with the Application's select() loop. /// WARNING: These functions are NOT thread-safe. They must only be called from the main loop From f54756ae2dcae30f91b4a5bb03d2eae55849f552 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 9 Mar 2026 23:56:37 -1000 Subject: [PATCH 04/20] [socket] Address review comments: doc comments and (void) flags - Document port_host byte order convention in lwip_ip_to_sockaddr - Note intentional method hiding in LWIPRawUDPRecvImpl - Note recvfrom truncation differs from POSIX MSG_TRUNC - Add (void) flags in sendto to clarify flags are ignored Co-Authored-By: J. Nick Koston --- esphome/components/socket/lwip_raw_tcp_impl.cpp | 4 ++++ esphome/components/socket/lwip_raw_tcp_impl.h | 7 +++++++ 2 files changed, 11 insertions(+) diff --git a/esphome/components/socket/lwip_raw_tcp_impl.cpp b/esphome/components/socket/lwip_raw_tcp_impl.cpp index 26d62cd298..159bc08a3f 100644 --- a/esphome/components/socket/lwip_raw_tcp_impl.cpp +++ b/esphome/components/socket/lwip_raw_tcp_impl.cpp @@ -145,6 +145,9 @@ static const char *const TAG = "socket.lwip"; // ---- Shared helpers ---- /// Convert lwip ip_addr_t + host-order port to sockaddr, based on the socket's address family. +/// @param port_host Port in host byte order. TCP callers must convert from network order first +/// (tcp_pcb stores ports in network byte order); UDP callers can pass directly +/// (lwip udp_recv callback provides port in host byte order). /// Shared by both TCP (LWIPRawCommon) and UDP (LWIPRawUDPImpl) implementations. static int lwip_ip_to_sockaddr(sa_family_t family, const ip_addr_t *ip, uint16_t port_host, struct sockaddr *name, socklen_t *addrlen) { @@ -895,6 +898,7 @@ int LWIPRawUDPImpl::ip2sockaddr_(const ip_addr_t *ip, uint16_t port, struct sock ssize_t LWIPRawUDPImpl::sendto(const void *buf, size_t len, int flags, const struct sockaddr *dest_addr, socklen_t addrlen) { + (void) flags; // Flags (MSG_DONTWAIT, etc.) are ignored; raw lwip is always non-blocking if (this->pcb_ == nullptr) { errno = EBADF; return -1; diff --git a/esphome/components/socket/lwip_raw_tcp_impl.h b/esphome/components/socket/lwip_raw_tcp_impl.h index 79ba82d438..f6cf136e9a 100644 --- a/esphome/components/socket/lwip_raw_tcp_impl.h +++ b/esphome/components/socket/lwip_raw_tcp_impl.h @@ -243,6 +243,11 @@ class LWIPRawUDPImpl { /// UDP socket with receive support for LWIP raw API. /// Extends LWIPRawUDPImpl with a fixed-size ring buffer for incoming packets. /// The recv callback is registered on bind(). +/// +/// Note: close() and bind() intentionally hide the base class methods to add +/// recv callback registration/cleanup. This is safe because these classes are +/// never used polymorphically (no virtual dispatch) — callers always use the +/// concrete LWIPRawUDPRecvImpl type via the UDPRecvSocket alias. class LWIPRawUDPRecvImpl : public LWIPRawUDPImpl { public: using LWIPRawUDPImpl::LWIPRawUDPImpl; @@ -256,9 +261,11 @@ class LWIPRawUDPRecvImpl : public LWIPRawUDPImpl { /// Read the next queued packet, discarding source address info. /// If buf is smaller than the packet, data is silently truncated (returns bytes copied). + /// Note: unlike POSIX MSG_TRUNC, this does not return the original packet length on truncation. ssize_t read(void *buf, size_t len); /// Read the next queued packet and return the source address. /// If buf is smaller than the packet, data is silently truncated (returns bytes copied). + /// Note: unlike POSIX MSG_TRUNC, this does not return the original packet length on truncation. ssize_t recvfrom(void *buf, size_t len, struct sockaddr *src_addr, socklen_t *addrlen); /// Returns true if there are packets available to read. From fccfab8083872656f62ae256f4cf9b288d4eb14e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 10 Mar 2026 01:15:57 -1000 Subject: [PATCH 05/20] [socket] Switch UDP rx queue from lock-free SPSC to count-based with LWIP_LOCK With the lwip lock infrastructure in place from the TCP race fix, the lock-free SPSC ring buffer's wasted slot is no longer needed. Switch to a simple rx_count_ approach that uses all 4 queue slots. Also add LWIP_LOCK() to all UDP methods that call lwip APIs (bind, close, sendto, setsockopt, getsockopt, recvfrom, factories). --- .../components/socket/lwip_raw_tcp_impl.cpp | 33 +++++++++++++------ esphome/components/socket/lwip_raw_tcp_impl.h | 18 +++++----- 2 files changed, 32 insertions(+), 19 deletions(-) diff --git a/esphome/components/socket/lwip_raw_tcp_impl.cpp b/esphome/components/socket/lwip_raw_tcp_impl.cpp index 159bc08a3f..27313b95e6 100644 --- a/esphome/components/socket/lwip_raw_tcp_impl.cpp +++ b/esphome/components/socket/lwip_raw_tcp_impl.cpp @@ -799,6 +799,7 @@ LWIPRawUDPImpl::LWIPRawUDPImpl(sa_family_t family) : family_(family) { } LWIPRawUDPImpl::~LWIPRawUDPImpl() { + LWIP_LOCK(); if (this->pcb_ != nullptr) { udp_remove(this->pcb_); this->pcb_ = nullptr; @@ -806,6 +807,7 @@ LWIPRawUDPImpl::~LWIPRawUDPImpl() { } int LWIPRawUDPImpl::bind_internal_(const struct sockaddr *name, socklen_t addrlen) { + LWIP_LOCK(); if (this->pcb_ == nullptr) { errno = EBADF; return -1; @@ -847,6 +849,7 @@ int LWIPRawUDPImpl::bind_internal_(const struct sockaddr *name, socklen_t addrle int LWIPRawUDPImpl::bind(const struct sockaddr *name, socklen_t addrlen) { return this->bind_internal_(name, addrlen); } int LWIPRawUDPImpl::close() { + LWIP_LOCK(); if (this->pcb_ == nullptr) { errno = EBADF; return -1; @@ -899,6 +902,7 @@ int LWIPRawUDPImpl::ip2sockaddr_(const ip_addr_t *ip, uint16_t port, struct sock ssize_t LWIPRawUDPImpl::sendto(const void *buf, size_t len, int flags, const struct sockaddr *dest_addr, socklen_t addrlen) { (void) flags; // Flags (MSG_DONTWAIT, etc.) are ignored; raw lwip is always non-blocking + LWIP_LOCK(); if (this->pcb_ == nullptr) { errno = EBADF; return -1; @@ -940,6 +944,7 @@ ssize_t LWIPRawUDPImpl::sendto(const void *buf, size_t len, int flags, const str } int LWIPRawUDPImpl::setsockopt(int level, int optname, const void *optval, socklen_t optlen) { + LWIP_LOCK(); if (this->pcb_ == nullptr) { errno = EBADF; return -1; @@ -1001,6 +1006,7 @@ int LWIPRawUDPImpl::setsockopt(int level, int optname, const void *optval, sockl } int LWIPRawUDPImpl::getsockopt(int level, int optname, void *optval, socklen_t *optlen) { + LWIP_LOCK(); if (this->pcb_ == nullptr) { errno = EBADF; return -1; @@ -1036,24 +1042,27 @@ LWIPRawUDPRecvImpl::~LWIPRawUDPRecvImpl() { } int LWIPRawUDPRecvImpl::close() { + LWIP_LOCK(); // Unregister recv callback before removing pcb if (this->pcb_ != nullptr) { udp_recv(this->pcb_, nullptr, nullptr); } // Flush any queued rx packets - while (this->rx_read_idx_ != this->rx_write_idx_) { + while (this->rx_count_ > 0) { auto &pkt = this->rx_queue_[this->rx_read_idx_]; if (pkt.pb != nullptr) { pbuf_free(pkt.pb); pkt.pb = nullptr; } this->rx_read_idx_ = (this->rx_read_idx_ + 1) & UDP_RX_MASK; + this->rx_count_--; } // close() returns EBADF if already closed, which is fine from destructor return LWIPRawUDPImpl::close(); } int LWIPRawUDPRecvImpl::bind(const struct sockaddr *name, socklen_t addrlen) { + LWIP_LOCK(); int ret = this->bind_internal_(name, addrlen); if (ret != 0) return ret; @@ -1073,7 +1082,8 @@ ssize_t LWIPRawUDPRecvImpl::recvfrom(void *buf, size_t len, struct sockaddr *src errno = EINVAL; return -1; } - if (this->rx_read_idx_ == this->rx_write_idx_) { + LWIP_LOCK(); + if (this->rx_count_ == 0) { errno = EWOULDBLOCK; return -1; } @@ -1090,11 +1100,11 @@ ssize_t LWIPRawUDPRecvImpl::recvfrom(void *buf, size_t len, struct sockaddr *src this->ip2sockaddr_(&pkt.src_addr, pkt.src_port, src_addr, addrlen); } - // Free the pbuf and advance the read pointer — must be last, - // as this publishes the slot to the producer (recv callback). + // Free the pbuf and advance the read pointer pbuf_free(pkt.pb); pkt.pb = nullptr; this->rx_read_idx_ = (this->rx_read_idx_ + 1) & UDP_RX_MASK; + this->rx_count_--; return (ssize_t) copy_len; } @@ -1104,24 +1114,25 @@ void LWIPRawUDPRecvImpl::s_recv_fn(void *arg, struct udp_pcb *pcb, struct pbuf * self->recv_fn_(p, addr, port); } +// Called by lwip core which already holds the async_context lock on RP2040. void LWIPRawUDPRecvImpl::recv_fn_(struct pbuf *p, const ip_addr_t *addr, u16_t port) { if (p == nullptr) return; - // Check if queue is full (next write position would collide with read position) - uint8_t next_write = (this->rx_write_idx_ + 1) & UDP_RX_MASK; - if (next_write == this->rx_read_idx_) { + // Check if queue is full + if (this->rx_count_ >= UDP_RX_QUEUE_SIZE) { // Drop packet — queue full pbuf_free(p); return; } - // Enqueue the packet — write data first, then publish by advancing write index. - auto &slot = this->rx_queue_[this->rx_write_idx_]; + // Enqueue the packet + uint8_t write_idx = (this->rx_read_idx_ + this->rx_count_) & UDP_RX_MASK; + auto &slot = this->rx_queue_[write_idx]; slot.pb = p; slot.src_addr = *addr; slot.src_port = port; - this->rx_write_idx_ = next_write; + this->rx_count_++; #if defined(USE_ESP8266) || defined(USE_RP2040) socket_wake(); @@ -1152,6 +1163,7 @@ std::unique_ptr socket_loop_monitored(int domain, int type, int protocol std::unique_ptr socket_udp(int domain, int protocol) { (void) protocol; // Raw lwip UDP ignores protocol; kept for API compatibility + LWIP_LOCK(); auto sock = make_unique((sa_family_t) domain); if (!sock->is_valid()) { errno = ENOMEM; @@ -1162,6 +1174,7 @@ std::unique_ptr socket_udp(int domain, int protocol) { std::unique_ptr socket_udp_recv(int domain, int protocol) { (void) protocol; // Raw lwip UDP ignores protocol; kept for API compatibility + LWIP_LOCK(); auto sock = make_unique((sa_family_t) domain); if (!sock->is_valid()) { errno = ENOMEM; diff --git a/esphome/components/socket/lwip_raw_tcp_impl.h b/esphome/components/socket/lwip_raw_tcp_impl.h index f6cf136e9a..4255611a8f 100644 --- a/esphome/components/socket/lwip_raw_tcp_impl.h +++ b/esphome/components/socket/lwip_raw_tcp_impl.h @@ -269,19 +269,19 @@ class LWIPRawUDPRecvImpl : public LWIPRawUDPImpl { ssize_t recvfrom(void *buf, size_t len, struct sockaddr *src_addr, socklen_t *addrlen); /// Returns true if there are packets available to read. - bool ready() const { return this->rx_read_idx_ != this->rx_write_idx_; } + /// Intentionally unlocked — same rationale as LWIPRawImpl::ready(). + bool ready() const { return this->rx_count_ > 0; } protected: static void s_recv_fn(void *arg, struct udp_pcb *pcb, struct pbuf *p, const ip_addr_t *addr, u16_t port); void recv_fn_(struct pbuf *p, const ip_addr_t *addr, u16_t port); - /// Lock-free SPSC ring buffer for received UDP packets. - /// Producer (recv callback, possibly IRQ context on RP2040) writes rx_write_idx_. - /// Consumer (main loop) writes rx_read_idx_. - /// No shared read-modify-write — safe without locking. - /// One slot is reserved to distinguish full from empty, giving 3 usable slots. + /// Ring buffer for received UDP packets. + /// Both producer (recv callback) and consumer (main loop) are serialized by the + /// lwip lock — the callback runs under lwip core lock, and consumer methods hold + /// LWIP_LOCK(). All 4 slots are usable (no wasted slot for full/empty distinction). /// No heap allocation in the recv callback — packets are dropped if the queue is full. - static constexpr uint8_t UDP_RX_QUEUE_SIZE = 4; // Must be power of 2 + static constexpr uint8_t UDP_RX_QUEUE_SIZE = 4; static constexpr uint8_t UDP_RX_MASK = UDP_RX_QUEUE_SIZE - 1; static_assert((UDP_RX_QUEUE_SIZE & UDP_RX_MASK) == 0, "UDP_RX_QUEUE_SIZE must be power of 2"); struct UDPRxPacket { @@ -290,8 +290,8 @@ class LWIPRawUDPRecvImpl : public LWIPRawUDPImpl { uint16_t src_port{0}; }; std::array rx_queue_{}; - volatile uint8_t rx_read_idx_{0}; ///< Written by consumer (main loop), read by producer - volatile uint8_t rx_write_idx_{0}; ///< Written by producer (recv callback), read by consumer + uint8_t rx_read_idx_{0}; + uint8_t rx_count_{0}; }; } // namespace esphome::socket From 1131af169039bf0e080322462675e273a25dc392 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 10 Mar 2026 23:59:25 -1000 Subject: [PATCH 06/20] safety --- esphome/components/socket/lwip_raw_tcp_impl.cpp | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/esphome/components/socket/lwip_raw_tcp_impl.cpp b/esphome/components/socket/lwip_raw_tcp_impl.cpp index eba8fa407f..f716f28e6f 100644 --- a/esphome/components/socket/lwip_raw_tcp_impl.cpp +++ b/esphome/components/socket/lwip_raw_tcp_impl.cpp @@ -1162,15 +1162,15 @@ int LWIPRawUDPRecvImpl::bind(const struct sockaddr *name, socklen_t addrlen) { ssize_t LWIPRawUDPRecvImpl::read(void *buf, size_t len) { return this->recvfrom(buf, len, nullptr, nullptr); } ssize_t LWIPRawUDPRecvImpl::recvfrom(void *buf, size_t len, struct sockaddr *src_addr, socklen_t *addrlen) { - if (this->pcb_ == nullptr) { - errno = EBADF; - return -1; - } if (buf == nullptr && len > 0) { errno = EINVAL; return -1; } LWIP_LOCK(); + if (this->pcb_ == nullptr) { + errno = EBADF; + return -1; + } if (this->rx_count_ == 0) { errno = EWOULDBLOCK; return -1; @@ -1202,7 +1202,9 @@ void LWIPRawUDPRecvImpl::s_recv_fn(void *arg, struct udp_pcb *pcb, struct pbuf * self->recv_fn_(p, addr, port); } -// Called by lwip core which already holds the async_context lock on RP2040. +// LWIP CALLBACK — runs from IRQ context on RP2040 (low-priority user IRQ). +// No heap allocation allowed — malloc is not IRQ-safe (see #14687). +// No LWIP_LOCK() needed — lwip core already holds the async_context lock. void LWIPRawUDPRecvImpl::recv_fn_(struct pbuf *p, const ip_addr_t *addr, u16_t port) { if (p == nullptr) return; From a0e162912cfb3595211faca81cd3eeb0c1d348d0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 11 Mar 2026 00:00:52 -1000 Subject: [PATCH 07/20] safety --- esphome/components/socket/lwip_raw_tcp_impl.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/esphome/components/socket/lwip_raw_tcp_impl.cpp b/esphome/components/socket/lwip_raw_tcp_impl.cpp index f716f28e6f..af27531f44 100644 --- a/esphome/components/socket/lwip_raw_tcp_impl.cpp +++ b/esphome/components/socket/lwip_raw_tcp_impl.cpp @@ -879,6 +879,7 @@ err_t LWIPRawListenImpl::accept_fn_(struct tcp_pcb *newpcb, err_t err) { // ---- LWIPRawUDPImpl (send-only) methods ---- LWIPRawUDPImpl::LWIPRawUDPImpl(sa_family_t family) : family_(family) { + LWIP_LOCK(); #if LWIP_IPV6 this->pcb_ = udp_new_ip_type(family == AF_INET6 ? IPADDR_TYPE_ANY : IPADDR_TYPE_V4); #else From 519be06e737cc62cb4c826d83141dcaaa7e15313 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 14 Mar 2026 15:57:57 -1000 Subject: [PATCH 08/20] [socket] Add LWIP_LOCK to UDP socket methods for RP2040 safety On RP2040, lwip callbacks run from a low-priority IRQ context and can preempt main-loop code. All lwip API calls from the main loop must hold the async_context lock (LWIP_LOCK) to prevent races on shared lwip state (PCB lists, pbuf pools, IGMP groups). The TCP implementation was already correct; the UDP methods were missing the lock. Co-Authored-By: Claude Opus 4.6 (1M context) --- esphome/components/socket/lwip_raw_tcp_impl.cpp | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/esphome/components/socket/lwip_raw_tcp_impl.cpp b/esphome/components/socket/lwip_raw_tcp_impl.cpp index 920f772d14..a730885ea5 100644 --- a/esphome/components/socket/lwip_raw_tcp_impl.cpp +++ b/esphome/components/socket/lwip_raw_tcp_impl.cpp @@ -974,6 +974,7 @@ err_t LWIPRawListenImpl::accept_fn_(struct tcp_pcb *newpcb, err_t err) { // ---- LWIPRawUDPImpl (send-only) methods ---- LWIPRawUDPImpl::LWIPRawUDPImpl(sa_family_t family) : family_(family) { + LWIP_LOCK(); #if LWIP_IPV6 this->pcb_ = udp_new_ip_type(family == AF_INET6 ? IPADDR_TYPE_ANY : IPADDR_TYPE_V4); #else @@ -982,6 +983,7 @@ LWIPRawUDPImpl::LWIPRawUDPImpl(sa_family_t family) : family_(family) { } LWIPRawUDPImpl::~LWIPRawUDPImpl() { + LWIP_LOCK(); if (this->pcb_ != nullptr) { udp_remove(this->pcb_); this->pcb_ = nullptr; @@ -989,6 +991,7 @@ LWIPRawUDPImpl::~LWIPRawUDPImpl() { } int LWIPRawUDPImpl::bind_internal_(const struct sockaddr *name, socklen_t addrlen) { + LWIP_LOCK(); if (this->pcb_ == nullptr) { errno = EBADF; return -1; @@ -1030,6 +1033,7 @@ int LWIPRawUDPImpl::bind_internal_(const struct sockaddr *name, socklen_t addrle int LWIPRawUDPImpl::bind(const struct sockaddr *name, socklen_t addrlen) { return this->bind_internal_(name, addrlen); } int LWIPRawUDPImpl::close() { + LWIP_LOCK(); if (this->pcb_ == nullptr) { errno = EBADF; return -1; @@ -1081,6 +1085,7 @@ int LWIPRawUDPImpl::ip2sockaddr_(const ip_addr_t *ip, uint16_t port, struct sock ssize_t LWIPRawUDPImpl::sendto(const void *buf, size_t len, int flags, const struct sockaddr *dest_addr, socklen_t addrlen) { + LWIP_LOCK(); (void) flags; // Flags (MSG_DONTWAIT, etc.) are ignored; raw lwip is always non-blocking if (this->pcb_ == nullptr) { errno = EBADF; @@ -1123,6 +1128,7 @@ ssize_t LWIPRawUDPImpl::sendto(const void *buf, size_t len, int flags, const str } int LWIPRawUDPImpl::setsockopt(int level, int optname, const void *optval, socklen_t optlen) { + LWIP_LOCK(); if (this->pcb_ == nullptr) { errno = EBADF; return -1; @@ -1219,7 +1225,9 @@ LWIPRawUDPRecvImpl::~LWIPRawUDPRecvImpl() { } int LWIPRawUDPRecvImpl::close() { - // Unregister recv callback before removing pcb + LWIP_LOCK(); + // Unregister recv callback before removing pcb — prevents new packets + // from being enqueued after we start flushing. if (this->pcb_ != nullptr) { udp_recv(this->pcb_, nullptr, nullptr); } @@ -1232,15 +1240,17 @@ int LWIPRawUDPRecvImpl::close() { } this->rx_read_idx_ = (this->rx_read_idx_ + 1) & UDP_RX_MASK; } - // close() returns EBADF if already closed, which is fine from destructor + // Base close() acquires LWIP_LOCK again (recursive/reentrant on RP2040) return LWIPRawUDPImpl::close(); } int LWIPRawUDPRecvImpl::bind(const struct sockaddr *name, socklen_t addrlen) { + // bind_internal_ acquires LWIP_LOCK (recursive/reentrant on RP2040) int ret = this->bind_internal_(name, addrlen); if (ret != 0) return ret; // Register recv callback now that we're bound and ready to receive + LWIP_LOCK(); udp_recv(this->pcb_, LWIPRawUDPRecvImpl::s_recv_fn, this); return 0; } @@ -1261,6 +1271,7 @@ ssize_t LWIPRawUDPRecvImpl::recvfrom(void *buf, size_t len, struct sockaddr *src return -1; } + LWIP_LOCK(); auto &pkt = this->rx_queue_[this->rx_read_idx_]; size_t pkt_len = pkt.pb->tot_len; size_t copy_len = std::min(len, pkt_len); From c81e9fd154df5edf5f79c08c082b907ce104ebd4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 14 Mar 2026 16:03:24 -1000 Subject: [PATCH 09/20] [socket] Deduplicate sockaddr parsing between TCP and UDP bind Extract sockaddr_to_lwip() from LWIPRawUDPImpl static method to a shared file-level function. Refactor LWIPRawCommon::bind() to use it instead of inline address parsing, removing ~35 lines of duplicated sockaddr-to-ip_addr_t conversion code. --- .../components/socket/lwip_raw_tcp_impl.cpp | 113 +++++++----------- esphome/components/socket/lwip_raw_tcp_impl.h | 2 - 2 files changed, 46 insertions(+), 69 deletions(-) diff --git a/esphome/components/socket/lwip_raw_tcp_impl.cpp b/esphome/components/socket/lwip_raw_tcp_impl.cpp index b4910a90de..6efc13acd8 100644 --- a/esphome/components/socket/lwip_raw_tcp_impl.cpp +++ b/esphome/components/socket/lwip_raw_tcp_impl.cpp @@ -220,6 +220,45 @@ static err_t pcb_detach_close(struct tcp_pcb *pcb) { return err; } +/// Convert sockaddr to lwip ip_addr_t and host-order port. +/// For IPv6, sets type to IPADDR_TYPE_V6 (callers that need dual-stack should +/// override to IPADDR_TYPE_ANY after calling). +/// Shared by both TCP (LWIPRawCommon) and UDP (LWIPRawUDPImpl) bind/sendto paths. +static bool sockaddr_to_lwip(const struct sockaddr *addr, socklen_t addrlen, ip_addr_t *ip, uint16_t *port) { + if (addrlen < sizeof(sa_family_t)) + return false; +#if LWIP_IPV6 + if (addr->sa_family == AF_INET) { + if (addrlen < sizeof(sockaddr_in)) + return false; + auto *addr4 = reinterpret_cast(addr); + *port = ntohs(addr4->sin_port); + ip->type = IPADDR_TYPE_V4; + ip->u_addr.ip4.addr = addr4->sin_addr.s_addr; + return true; + } + if (addr->sa_family == AF_INET6) { + if (addrlen < sizeof(sockaddr_in6)) + return false; + auto *addr6 = reinterpret_cast(addr); + *port = ntohs(addr6->sin6_port); + ip->type = IPADDR_TYPE_V6; + memcpy(&ip->u_addr.ip6.addr, &addr6->sin6_addr.un.u8_addr, 16); + return true; + } +#else + if (addr->sa_family == AF_INET) { + if (addrlen < sizeof(sockaddr_in)) + return false; + auto *addr4 = reinterpret_cast(addr); + *port = ntohs(addr4->sin_port); + ip->addr = addr4->sin_addr.s_addr; + return true; + } +#endif + return false; +} + // ---- LWIPRawCommon methods ---- LWIPRawCommon::~LWIPRawCommon() { @@ -242,41 +281,16 @@ int LWIPRawCommon::bind(const struct sockaddr *name, socklen_t addrlen) { return -1; } ip_addr_t ip; - in_port_t port; + uint16_t port; + if (!sockaddr_to_lwip(name, addrlen, &ip, &port)) { + errno = EINVAL; + return -1; + } #if LWIP_IPV6 - if (this->family_ == AF_INET) { - if (addrlen < sizeof(sockaddr_in)) { - errno = EINVAL; - return -1; - } - auto *addr4 = reinterpret_cast(name); - port = ntohs(addr4->sin_port); - ip.type = IPADDR_TYPE_V4; - ip.u_addr.ip4.addr = addr4->sin_addr.s_addr; - LWIP_LOG("tcp_bind(%p ip=%s port=%u)", this->pcb_, ip4addr_ntoa(&ip.u_addr.ip4), port); - } else if (this->family_ == AF_INET6) { - if (addrlen < sizeof(sockaddr_in6)) { - errno = EINVAL; - return -1; - } - auto *addr6 = reinterpret_cast(name); - port = ntohs(addr6->sin6_port); + // Use IPADDR_TYPE_ANY for dual-stack (accept both IPv4 and IPv6) + if (this->family_ == AF_INET6) { ip.type = IPADDR_TYPE_ANY; - memcpy(&ip.u_addr.ip6.addr, &addr6->sin6_addr.un.u8_addr, 16); - LWIP_LOG("tcp_bind(%p ip=%s port=%u)", this->pcb_, ip6addr_ntoa(&ip.u_addr.ip6), port); - } else { - errno = EINVAL; - return -1; } -#else - if (this->family_ != AF_INET) { - errno = EINVAL; - return -1; - } - auto *addr4 = reinterpret_cast(name); - port = ntohs(addr4->sin_port); - ip.addr = addr4->sin_addr.s_addr; - LWIP_LOG("tcp_bind(%p ip=%u port=%u)", this->pcb_, ip.addr, port); #endif err_t err = tcp_bind(this->pcb_, &ip, port); if (err == ERR_USE) { @@ -980,41 +994,6 @@ int LWIPRawUDPImpl::close() { return 0; } -bool LWIPRawUDPImpl::sockaddr_to_lwip(const struct sockaddr *addr, socklen_t addrlen, ip_addr_t *ip, uint16_t *port) { - if (addrlen < sizeof(sa_family_t)) - return false; -#if LWIP_IPV6 - if (addr->sa_family == AF_INET) { - if (addrlen < sizeof(sockaddr_in)) - return false; - auto *addr4 = reinterpret_cast(addr); - *port = ntohs(addr4->sin_port); - ip->type = IPADDR_TYPE_V4; - ip->u_addr.ip4.addr = addr4->sin_addr.s_addr; - return true; - } - if (addr->sa_family == AF_INET6) { - if (addrlen < sizeof(sockaddr_in6)) - return false; - auto *addr6 = reinterpret_cast(addr); - *port = ntohs(addr6->sin6_port); - ip->type = IPADDR_TYPE_V6; - memcpy(&ip->u_addr.ip6.addr, &addr6->sin6_addr.un.u8_addr, 16); - return true; - } -#else - if (addr->sa_family == AF_INET) { - if (addrlen < sizeof(sockaddr_in)) - return false; - auto *addr4 = reinterpret_cast(addr); - *port = ntohs(addr4->sin_port); - ip->addr = addr4->sin_addr.s_addr; - return true; - } -#endif - return false; -} - int LWIPRawUDPImpl::ip2sockaddr_(const ip_addr_t *ip, uint16_t port, struct sockaddr *name, socklen_t *addrlen) { // UDP recv callback provides port in host byte order return lwip_ip_to_sockaddr(this->family_, ip, port, name, addrlen); diff --git a/esphome/components/socket/lwip_raw_tcp_impl.h b/esphome/components/socket/lwip_raw_tcp_impl.h index ba9bc7e4be..6c2f7d517e 100644 --- a/esphome/components/socket/lwip_raw_tcp_impl.h +++ b/esphome/components/socket/lwip_raw_tcp_impl.h @@ -233,8 +233,6 @@ class LWIPRawUDPImpl { int get_fd() const { return -1; } protected: - /// Convert sockaddr to lwip ip_addr_t and port. - static bool sockaddr_to_lwip(const struct sockaddr *addr, socklen_t addrlen, ip_addr_t *ip, uint16_t *port); /// Convert lwip ip_addr_t and port to sockaddr. int ip2sockaddr_(const ip_addr_t *ip, uint16_t port, struct sockaddr *name, socklen_t *addrlen); From 0176305d24780ccd88127394aaf2ede27e232079 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 14 Mar 2026 16:07:15 -1000 Subject: [PATCH 10/20] [socket] Restore read_locked_, SO_RCVTIMEO, and wait_for_data_ lost in merge These features from upstream/dev were dropped when resolving conflicts with the PR's remote branch: read_locked_/wait_for_data_ (blocking read with SO_RCVTIMEO timeout support), recv_timeout_cs_ field, SO_RCVTIMEO and SO_SNDTIMEO setsockopt/getsockopt handling, and the setblocking() implementation that accepts blocking mode for SO_RCVTIMEO. --- .../components/socket/lwip_raw_tcp_impl.cpp | 66 ++++++++++++++++++- esphome/components/socket/lwip_raw_tcp_impl.h | 16 +++-- 2 files changed, 74 insertions(+), 8 deletions(-) diff --git a/esphome/components/socket/lwip_raw_tcp_impl.cpp b/esphome/components/socket/lwip_raw_tcp_impl.cpp index 6efc13acd8..5b8ad5b358 100644 --- a/esphome/components/socket/lwip_raw_tcp_impl.cpp +++ b/esphome/components/socket/lwip_raw_tcp_impl.cpp @@ -5,6 +5,7 @@ #include #include +#include #include "esphome/core/helpers.h" #include "esphome/core/log.h" @@ -422,6 +423,18 @@ int LWIPRawCommon::getsockopt(int level, int optname, void *optval, socklen_t *o *optlen = 4; return 0; } + if (level == SOL_SOCKET && optname == SO_RCVTIMEO) { + if (*optlen < sizeof(struct timeval)) { + errno = EINVAL; + return -1; + } + uint32_t ms = this->recv_timeout_cs_ * 10; + auto *tv = reinterpret_cast(optval); + tv->tv_sec = ms / 1000; + tv->tv_usec = (ms % 1000) * 1000; + *optlen = sizeof(struct timeval); + return 0; + } if (level == IPPROTO_TCP && optname == TCP_NODELAY) { if (*optlen < 4) { errno = EINVAL; @@ -451,6 +464,21 @@ int LWIPRawCommon::setsockopt(int level, int optname, const void *optval, sockle // to prevent warnings return 0; } + if (level == SOL_SOCKET && optname == SO_RCVTIMEO) { + if (optlen < sizeof(struct timeval)) { + errno = EINVAL; + return -1; + } + const auto *tv = reinterpret_cast(optval); + uint32_t ms = tv->tv_sec * 1000 + tv->tv_usec / 1000; + uint32_t cs = (ms + 9) / 10; // round up to nearest centisecond + this->recv_timeout_cs_ = cs > 255 ? 255 : static_cast(cs); + return 0; + } + if (level == SOL_SOCKET && optname == SO_SNDTIMEO) { + // Raw TCP writes are non-blocking (tcp_write), so send timeout is a no-op. + return 0; + } if (level == IPPROTO_TCP && optname == TCP_NODELAY) { if (optlen != 4) { errno = EINVAL; @@ -546,8 +574,25 @@ err_t LWIPRawImpl::recv_fn(struct pbuf *pb, err_t err) { return ERR_OK; } -ssize_t LWIPRawImpl::read(void *buf, size_t len) { - LWIP_LOCK(); +void LWIPRawImpl::wait_for_data_() { + // Wait for data without holding LWIP_LOCK so recv_fn() can run on RP2040 + // (needs async_context lock). + // + // Loop until data arrives, connection closes, or the full timeout elapses. + // socket_delay() may return early due to other sockets waking the global + // socket_wake() flag, so we re-enter for the remaining time. + uint32_t timeout_ms = this->recv_timeout_cs_ * 10; + uint32_t start = millis(); + while (this->waiting_for_data_()) { + uint32_t elapsed = millis() - start; + if (elapsed >= timeout_ms) + break; + socket_delay(timeout_ms - elapsed); + } +} + +ssize_t LWIPRawImpl::read_locked_(void *buf, size_t len) { + // Caller must hold LWIP_LOCK. Copies available data from rx_buf_ into buf. if (this->pcb_ == nullptr) { errno = ECONNRESET; return -1; @@ -606,11 +651,26 @@ ssize_t LWIPRawImpl::read(void *buf, size_t len) { return read; } +ssize_t LWIPRawImpl::read(void *buf, size_t len) { + // See waiting_for_data_() for safety of unlocked reads. + if (this->recv_timeout_cs_ > 0 && this->waiting_for_data_()) { + this->wait_for_data_(); + } + + LWIP_LOCK(); + return this->read_locked_(buf, len); +} + ssize_t LWIPRawImpl::readv(const struct iovec *iov, int iovcnt) { + // See waiting_for_data_() for safety of unlocked reads. + if (this->recv_timeout_cs_ > 0 && this->waiting_for_data_()) { + this->wait_for_data_(); + } + LWIP_LOCK(); // Hold for entire scatter-gather operation ssize_t ret = 0; for (int i = 0; i < iovcnt; i++) { - ssize_t err = this->read(reinterpret_cast(iov[i].iov_base), iov[i].iov_len); + ssize_t err = this->read_locked_(reinterpret_cast(iov[i].iov_base), iov[i].iov_len); if (err == -1) { if (ret != 0) { // if we already read some don't return an error diff --git a/esphome/components/socket/lwip_raw_tcp_impl.h b/esphome/components/socket/lwip_raw_tcp_impl.h index 6c2f7d517e..8b958add05 100644 --- a/esphome/components/socket/lwip_raw_tcp_impl.h +++ b/esphome/components/socket/lwip_raw_tcp_impl.h @@ -58,6 +58,7 @@ class LWIPRawCommon { // instead use it for determining whether to call lwip_output bool nodelay_ = false; sa_family_t family_ = 0; + uint8_t recv_timeout_cs_ = 0; // SO_RCVTIMEO in centiseconds (0 = no timeout, max 2.55s) }; /// Connected socket implementation for LWIP raw TCP. @@ -108,11 +109,8 @@ class LWIPRawImpl : public LWIPRawCommon { errno = ECONNRESET; return -1; } - if (blocking) { - // blocking operation not supported - errno = EINVAL; - return -1; - } + // Raw TCP doesn't use a blocking flag directly. Blocking behavior + // is provided by SO_RCVTIMEO which makes read() wait via socket_delay(). return 0; } int loop() { return 0; } @@ -123,6 +121,14 @@ class LWIPRawImpl : public LWIPRawCommon { static err_t s_recv_fn(void *arg, struct tcp_pcb *pcb, struct pbuf *pb, err_t err); protected: + // True when the socket could receive data but none has arrived yet. + // Safe to call without LWIP_LOCK — only null-checks pointers and reads a bool, + // all atomic on ARM/Xtensa. A stale value is harmless: the caller either does + // an unnecessary wait (stale true) or skips it (stale false), and the + // authoritative recheck happens under LWIP_LOCK afterward. + bool waiting_for_data_() const { return this->rx_buf_ == nullptr && !this->rx_closed_ && this->pcb_ != nullptr; } + void wait_for_data_(); + ssize_t read_locked_(void *buf, size_t len); ssize_t internal_write_(const void *buf, size_t len); int internal_output_(); From 71da3dc2de5247d67091b9fb3e6b6a8ad69799b4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 14 Mar 2026 16:09:24 -1000 Subject: [PATCH 11/20] [socket] Fix RP2040 socket_delay race: don't clear wake flag before sleep Restore the comment explaining why s_socket_woke must not be cleared between the early-return check and the __wfe() loop, and restore the s_socket_woke = false after the loop to consume the wake for the next call. Both were lost during conflict resolution. --- esphome/components/socket/lwip_raw_tcp_impl.cpp | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/esphome/components/socket/lwip_raw_tcp_impl.cpp b/esphome/components/socket/lwip_raw_tcp_impl.cpp index 5b8ad5b358..6a03a5fba5 100644 --- a/esphome/components/socket/lwip_raw_tcp_impl.cpp +++ b/esphome/components/socket/lwip_raw_tcp_impl.cpp @@ -86,7 +86,9 @@ void socket_delay(uint32_t ms) { s_socket_woke = false; return; } - s_socket_woke = false; + // Don't clear s_socket_woke here — if an IRQ fires between the check above + // and the while loop below, the while condition sees it immediately. Clearing + // here would lose that wake and sleep until the timer fires. s_delay_expired = false; // Set a one-shot timer to wake us after the timeout. // add_alarm_in_ms returns >0 on success, 0 if time already passed, <0 on error. @@ -104,6 +106,7 @@ void socket_delay(uint32_t ms) { // Cancel timer if we woke early (socket data arrived before timeout) if (!s_delay_expired) cancel_alarm(alarm); + s_socket_woke = false; // consume the wake for next call } // No IRAM_ATTR equivalent needed: on RP2040, CYW43 async_context runs LWIP From cdbbcfb87d0b2efeb1cc76bd0ee58851ab212050 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 14 Mar 2026 16:12:39 -1000 Subject: [PATCH 12/20] [socket] Extract lwip_bind_err() to deduplicate bind error handling TCP bind and UDP bind_internal_ had identical ERR_USE/ERR_VAL/ERR_OK to errno mapping. Extract into a shared helper. --- .../components/socket/lwip_raw_tcp_impl.cpp | 47 +++++++------------ 1 file changed, 17 insertions(+), 30 deletions(-) diff --git a/esphome/components/socket/lwip_raw_tcp_impl.cpp b/esphome/components/socket/lwip_raw_tcp_impl.cpp index 6a03a5fba5..2609968eab 100644 --- a/esphome/components/socket/lwip_raw_tcp_impl.cpp +++ b/esphome/components/socket/lwip_raw_tcp_impl.cpp @@ -263,6 +263,20 @@ static bool sockaddr_to_lwip(const struct sockaddr *addr, socklen_t addrlen, ip_ return false; } +/// Map lwip bind error to errno. Returns 0 on success, -1 on error with errno set. +static int lwip_bind_err(err_t err) { + if (err == ERR_OK) + return 0; + if (err == ERR_USE) { + errno = EADDRINUSE; + } else if (err == ERR_VAL) { + errno = EINVAL; + } else { + errno = EIO; + } + return -1; +} + // ---- LWIPRawCommon methods ---- LWIPRawCommon::~LWIPRawCommon() { @@ -297,22 +311,8 @@ int LWIPRawCommon::bind(const struct sockaddr *name, socklen_t addrlen) { } #endif err_t err = tcp_bind(this->pcb_, &ip, port); - if (err == ERR_USE) { - LWIP_LOG(" -> err ERR_USE"); - errno = EADDRINUSE; - return -1; - } - if (err == ERR_VAL) { - LWIP_LOG(" -> err ERR_VAL"); - errno = EINVAL; - return -1; - } - if (err != ERR_OK) { - LWIP_LOG(" -> err %d", err); - errno = EIO; - return -1; - } - return 0; + LWIP_LOG(" -> err %d", err); + return lwip_bind_err(err); } int LWIPRawCommon::close() { @@ -1028,20 +1028,7 @@ int LWIPRawUDPImpl::bind_internal_(const struct sockaddr *name, socklen_t addrle ip.type = IPADDR_TYPE_ANY; } #endif - err_t err = udp_bind(this->pcb_, &ip, port); - if (err == ERR_USE) { - errno = EADDRINUSE; - return -1; - } - if (err == ERR_VAL) { - errno = EINVAL; - return -1; - } - if (err != ERR_OK) { - errno = EIO; - return -1; - } - return 0; + return lwip_bind_err(udp_bind(this->pcb_, &ip, port)); } int LWIPRawUDPImpl::bind(const struct sockaddr *name, socklen_t addrlen) { return this->bind_internal_(name, addrlen); } From dde81d3f63190621dd9b1f44c77cc432d4683ab2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 16 Mar 2026 10:51:54 -1000 Subject: [PATCH 13/20] tweaks --- .../components/socket/lwip_raw_tcp_impl.cpp | 22 ++++++++++++------- esphome/components/socket/lwip_raw_tcp_impl.h | 7 ++++-- 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/esphome/components/socket/lwip_raw_tcp_impl.cpp b/esphome/components/socket/lwip_raw_tcp_impl.cpp index 031385c36d..c52241e406 100644 --- a/esphome/components/socket/lwip_raw_tcp_impl.cpp +++ b/esphome/components/socket/lwip_raw_tcp_impl.cpp @@ -1005,8 +1005,8 @@ LWIPRawUDPImpl::~LWIPRawUDPImpl() { } } -int LWIPRawUDPImpl::bind_internal_(const struct sockaddr *name, socklen_t addrlen) { - LWIP_LOCK(); +int LWIPRawUDPImpl::bind_internal_locked_(const struct sockaddr *name, socklen_t addrlen) { + // Caller must hold LWIP_LOCK if (this->pcb_ == nullptr) { errno = EBADF; return -1; @@ -1032,10 +1032,18 @@ int LWIPRawUDPImpl::bind_internal_(const struct sockaddr *name, socklen_t addrle return lwip_bind_err(udp_bind(this->pcb_, &ip, port)); } -int LWIPRawUDPImpl::bind(const struct sockaddr *name, socklen_t addrlen) { return this->bind_internal_(name, addrlen); } +int LWIPRawUDPImpl::bind(const struct sockaddr *name, socklen_t addrlen) { + LWIP_LOCK(); + return this->bind_internal_locked_(name, addrlen); +} int LWIPRawUDPImpl::close() { LWIP_LOCK(); + return this->close_internal_locked_(); +} + +int LWIPRawUDPImpl::close_internal_locked_() { + // Caller must hold LWIP_LOCK if (this->pcb_ == nullptr) { errno = EBADF; return -1; @@ -1208,13 +1216,13 @@ int LWIPRawUDPRecvImpl::close() { this->rx_read_idx_ = (this->rx_read_idx_ + 1) & UDP_RX_MASK; this->rx_count_--; } - // close() returns EBADF if already closed, which is fine from destructor - return LWIPRawUDPImpl::close(); + // close_internal_locked_() returns EBADF if already closed, which is fine from destructor + return this->close_internal_locked_(); } int LWIPRawUDPRecvImpl::bind(const struct sockaddr *name, socklen_t addrlen) { LWIP_LOCK(); - int ret = this->bind_internal_(name, addrlen); + int ret = this->bind_internal_locked_(name, addrlen); if (ret != 0) return ret; // Register recv callback now that we're bound and ready to receive @@ -1316,7 +1324,6 @@ std::unique_ptr socket_loop_monitored(int domain, int type, int protocol std::unique_ptr socket_udp(int domain, int protocol) { (void) protocol; // Raw lwip UDP ignores protocol; kept for API compatibility - LWIP_LOCK(); auto sock = make_unique((sa_family_t) domain); if (!sock->is_valid()) { errno = ENOMEM; @@ -1327,7 +1334,6 @@ std::unique_ptr socket_udp(int domain, int protocol) { std::unique_ptr socket_udp_recv(int domain, int protocol) { (void) protocol; // Raw lwip UDP ignores protocol; kept for API compatibility - LWIP_LOCK(); auto sock = make_unique((sa_family_t) domain); if (!sock->is_valid()) { errno = ENOMEM; diff --git a/esphome/components/socket/lwip_raw_tcp_impl.h b/esphome/components/socket/lwip_raw_tcp_impl.h index 8b958add05..5900950707 100644 --- a/esphome/components/socket/lwip_raw_tcp_impl.h +++ b/esphome/components/socket/lwip_raw_tcp_impl.h @@ -242,8 +242,11 @@ class LWIPRawUDPImpl { /// Convert lwip ip_addr_t and port to sockaddr. int ip2sockaddr_(const ip_addr_t *ip, uint16_t port, struct sockaddr *name, socklen_t *addrlen); - /// Shared bind logic — parses sockaddr and calls udp_bind. - int bind_internal_(const struct sockaddr *name, socklen_t addrlen); + /// Shared bind logic — parses sockaddr and calls udp_bind. Caller must hold LWIP_LOCK. + int bind_internal_locked_(const struct sockaddr *name, socklen_t addrlen); + + /// Shared close logic — unregisters and removes udp pcb. Caller must hold LWIP_LOCK. + int close_internal_locked_(); struct udp_pcb *pcb_{nullptr}; sa_family_t family_{0}; From 402398b38995798e7c90829395019a60ac7a449a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 16 Mar 2026 10:55:33 -1000 Subject: [PATCH 14/20] tweaks --- esphome/components/socket/lwip_raw_tcp_impl.cpp | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/esphome/components/socket/lwip_raw_tcp_impl.cpp b/esphome/components/socket/lwip_raw_tcp_impl.cpp index c52241e406..fa4eb28f31 100644 --- a/esphome/components/socket/lwip_raw_tcp_impl.cpp +++ b/esphome/components/socket/lwip_raw_tcp_impl.cpp @@ -998,11 +998,13 @@ LWIPRawUDPImpl::LWIPRawUDPImpl(sa_family_t family) : family_(family) { } LWIPRawUDPImpl::~LWIPRawUDPImpl() { + // Early return avoids acquiring the lwip lock when pcb_ is already null + // (e.g., after LWIPRawUDPRecvImpl::close() already cleaned up). + if (this->pcb_ == nullptr) + return; LWIP_LOCK(); - if (this->pcb_ != nullptr) { - udp_remove(this->pcb_); - this->pcb_ = nullptr; - } + udp_remove(this->pcb_); + this->pcb_ = nullptr; } int LWIPRawUDPImpl::bind_internal_locked_(const struct sockaddr *name, socklen_t addrlen) { From 86e4341a5214176e45c9e99235feb28023c1d7b4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 16 Mar 2026 11:00:57 -1000 Subject: [PATCH 15/20] tweaks --- esphome/components/socket/lwip_raw_tcp_impl.cpp | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/esphome/components/socket/lwip_raw_tcp_impl.cpp b/esphome/components/socket/lwip_raw_tcp_impl.cpp index fa4eb28f31..37d0edde68 100644 --- a/esphome/components/socket/lwip_raw_tcp_impl.cpp +++ b/esphome/components/socket/lwip_raw_tcp_impl.cpp @@ -1256,9 +1256,14 @@ ssize_t LWIPRawUDPRecvImpl::recvfrom(void *buf, size_t len, struct sockaddr *src // Copy data from pbuf chain pbuf_copy_partial(pkt.pb, buf, copy_len, 0); - // Fill in source address if requested + // Fill in source address if requested. + // If ip2sockaddr_ fails (e.g., addrlen too small), fail the entire recvfrom + // rather than silently returning data without a source address. if (src_addr != nullptr && addrlen != nullptr) { - this->ip2sockaddr_(&pkt.src_addr, pkt.src_port, src_addr, addrlen); + if (this->ip2sockaddr_(&pkt.src_addr, pkt.src_port, src_addr, addrlen) != 0) { + // Put the packet back — don't consume it on address conversion failure + return -1; + } } // Free the pbuf and advance the read pointer From 75efdd86621b11dde2734c76224f556af0fd8c45 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 16 Mar 2026 11:01:21 -1000 Subject: [PATCH 16/20] tweaks --- esphome/components/socket/lwip_raw_tcp_impl.cpp | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/esphome/components/socket/lwip_raw_tcp_impl.cpp b/esphome/components/socket/lwip_raw_tcp_impl.cpp index 37d0edde68..430eecdf6e 100644 --- a/esphome/components/socket/lwip_raw_tcp_impl.cpp +++ b/esphome/components/socket/lwip_raw_tcp_impl.cpp @@ -1259,11 +1259,10 @@ ssize_t LWIPRawUDPRecvImpl::recvfrom(void *buf, size_t len, struct sockaddr *src // Fill in source address if requested. // If ip2sockaddr_ fails (e.g., addrlen too small), fail the entire recvfrom // rather than silently returning data without a source address. - if (src_addr != nullptr && addrlen != nullptr) { - if (this->ip2sockaddr_(&pkt.src_addr, pkt.src_port, src_addr, addrlen) != 0) { - // Put the packet back — don't consume it on address conversion failure - return -1; - } + if (src_addr != nullptr && addrlen != nullptr && + this->ip2sockaddr_(&pkt.src_addr, pkt.src_port, src_addr, addrlen) != 0) { + // Don't consume the packet on address conversion failure + return -1; } // Free the pbuf and advance the read pointer From 0f4f5aa3929ef6b3b5c2507be0804c76dff94829 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 16 Mar 2026 16:22:08 -1000 Subject: [PATCH 17/20] [esp32_ble, esp32_ble_server] Inline is_active/is_running and remove STL bloat from BLE server loop Move ESP32BLE::is_active() and BLEServer::is_running() to headers with ESPHOME_ALWAYS_INLINE to eliminate cross-TU call overhead on every loop iteration. Replace std::remove_if + erase in BLEServer::loop() with a simple index-based compacting loop, removing ~250 bytes of unrolled STL template machinery. --- esphome/components/esp32_ble/ble.cpp | 2 -- esphome/components/esp32_ble/ble.h | 2 +- .../components/esp32_ble_server/ble_server.cpp | 17 ++++++++--------- .../components/esp32_ble_server/ble_server.h | 2 +- 4 files changed, 10 insertions(+), 13 deletions(-) diff --git a/esphome/components/esp32_ble/ble.cpp b/esphome/components/esp32_ble/ble.cpp index ff9d9bb15a..d17e259199 100644 --- a/esphome/components/esp32_ble/ble.cpp +++ b/esphome/components/esp32_ble/ble.cpp @@ -81,8 +81,6 @@ void ESP32BLE::disable() { this->state_ = BLE_COMPONENT_STATE_DISABLE; } -bool ESP32BLE::is_active() { return this->state_ == BLE_COMPONENT_STATE_ACTIVE; } - #ifdef USE_ESP32_BLE_ADVERTISING void ESP32BLE::advertising_start() { this->advertising_init_(); diff --git a/esphome/components/esp32_ble/ble.h b/esphome/components/esp32_ble/ble.h index 04bec3f785..357ea9df2a 100644 --- a/esphome/components/esp32_ble/ble.h +++ b/esphome/components/esp32_ble/ble.h @@ -135,7 +135,7 @@ class ESP32BLE : public Component { void enable(); void disable(); - bool is_active(); + ESPHOME_ALWAYS_INLINE bool is_active() { return this->state_ == BLE_COMPONENT_STATE_ACTIVE; } void setup() override; void loop() override; void dump_config() override; diff --git a/esphome/components/esp32_ble_server/ble_server.cpp b/esphome/components/esp32_ble_server/ble_server.cpp index ecc53e197f..48b05d1e16 100644 --- a/esphome/components/esp32_ble_server/ble_server.cpp +++ b/esphome/components/esp32_ble_server/ble_server.cpp @@ -7,7 +7,6 @@ #ifdef USE_ESP32 -#include #include #include #include @@ -39,16 +38,18 @@ void BLEServer::loop() { case RUNNING: { // Start all services that are pending to start if (!this->services_to_start_.empty()) { - for (auto &service : this->services_to_start_) { + size_t write_idx = 0; + for (size_t i = 0; i < this->services_to_start_.size(); i++) { + BLEService *service = this->services_to_start_[i]; if (service->is_created()) { service->start(); // Needs to be called once per characteristic in the service } + // Remove services that have started or are starting + if (!service->is_starting() && !service->is_running()) { + this->services_to_start_[write_idx++] = service; + } } - // Remove services that have been started - this->services_to_start_.erase( - std::remove_if(this->services_to_start_.begin(), this->services_to_start_.end(), - [](BLEService *service) { return service->is_starting() || service->is_running(); }), - this->services_to_start_.end()); + this->services_to_start_.erase(this->services_to_start_.begin() + write_idx, this->services_to_start_.end()); } break; } @@ -91,8 +92,6 @@ void BLEServer::loop() { } } -bool BLEServer::is_running() { return this->parent_->is_active() && this->state_ == RUNNING; } - bool BLEServer::can_proceed() { return this->is_running() || !this->parent_->is_active(); } void BLEServer::restart_advertising_() { diff --git a/esphome/components/esp32_ble_server/ble_server.h b/esphome/components/esp32_ble_server/ble_server.h index ff7e0044e4..1b419d2ee4 100644 --- a/esphome/components/esp32_ble_server/ble_server.h +++ b/esphome/components/esp32_ble_server/ble_server.h @@ -32,7 +32,7 @@ class BLEServer : public Component, public GATTsEventHandler, public BLEStatusEv float get_setup_priority() const override; bool can_proceed() override; - bool is_running(); + ESPHOME_ALWAYS_INLINE bool is_running() { return this->parent_->is_active() && this->state_ == RUNNING; } void set_manufacturer_data(const std::vector &data) { this->manufacturer_data_ = data; From 273637b6d7914c6326731b53a63bad426cf9e3eb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 16 Mar 2026 16:35:32 -1000 Subject: [PATCH 18/20] tweak --- esphome/components/socket/lwip_raw_tcp_impl.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/socket/lwip_raw_tcp_impl.cpp b/esphome/components/socket/lwip_raw_tcp_impl.cpp index 430eecdf6e..69a8e0e9c8 100644 --- a/esphome/components/socket/lwip_raw_tcp_impl.cpp +++ b/esphome/components/socket/lwip_raw_tcp_impl.cpp @@ -230,7 +230,7 @@ static err_t pcb_detach_close(struct tcp_pcb *pcb) { /// override to IPADDR_TYPE_ANY after calling). /// Shared by both TCP (LWIPRawCommon) and UDP (LWIPRawUDPImpl) bind/sendto paths. static bool sockaddr_to_lwip(const struct sockaddr *addr, socklen_t addrlen, ip_addr_t *ip, uint16_t *port) { - if (addrlen < sizeof(sa_family_t)) + if (addrlen < sizeof(struct sockaddr)) return false; #if LWIP_IPV6 if (addr->sa_family == AF_INET) { From 2fe09b1e9421897e48318745d7f3dbd3ee5ec98c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 16 Mar 2026 16:22:08 -1000 Subject: [PATCH 19/20] [esp32_ble, esp32_ble_server] Inline is_active/is_running and remove STL bloat from BLE server loop Move ESP32BLE::is_active() and BLEServer::is_running() to headers with ESPHOME_ALWAYS_INLINE to eliminate cross-TU call overhead on every loop iteration. Replace std::remove_if + erase in BLEServer::loop() with a simple index-based compacting loop, removing ~250 bytes of unrolled STL template machinery. --- esphome/components/esp32_ble/ble.cpp | 2 -- esphome/components/esp32_ble/ble.h | 2 +- .../components/esp32_ble_server/ble_server.cpp | 16 +++++++--------- esphome/components/esp32_ble_server/ble_server.h | 2 +- 4 files changed, 9 insertions(+), 13 deletions(-) diff --git a/esphome/components/esp32_ble/ble.cpp b/esphome/components/esp32_ble/ble.cpp index ff9d9bb15a..d17e259199 100644 --- a/esphome/components/esp32_ble/ble.cpp +++ b/esphome/components/esp32_ble/ble.cpp @@ -81,8 +81,6 @@ void ESP32BLE::disable() { this->state_ = BLE_COMPONENT_STATE_DISABLE; } -bool ESP32BLE::is_active() { return this->state_ == BLE_COMPONENT_STATE_ACTIVE; } - #ifdef USE_ESP32_BLE_ADVERTISING void ESP32BLE::advertising_start() { this->advertising_init_(); diff --git a/esphome/components/esp32_ble/ble.h b/esphome/components/esp32_ble/ble.h index 04bec3f785..357ea9df2a 100644 --- a/esphome/components/esp32_ble/ble.h +++ b/esphome/components/esp32_ble/ble.h @@ -135,7 +135,7 @@ class ESP32BLE : public Component { void enable(); void disable(); - bool is_active(); + ESPHOME_ALWAYS_INLINE bool is_active() { return this->state_ == BLE_COMPONENT_STATE_ACTIVE; } void setup() override; void loop() override; void dump_config() override; diff --git a/esphome/components/esp32_ble_server/ble_server.cpp b/esphome/components/esp32_ble_server/ble_server.cpp index ecc53e197f..a2512a8236 100644 --- a/esphome/components/esp32_ble_server/ble_server.cpp +++ b/esphome/components/esp32_ble_server/ble_server.cpp @@ -7,7 +7,6 @@ #ifdef USE_ESP32 -#include #include #include #include @@ -39,16 +38,17 @@ void BLEServer::loop() { case RUNNING: { // Start all services that are pending to start if (!this->services_to_start_.empty()) { - for (auto &service : this->services_to_start_) { + size_t write_idx = 0; + for (auto service : this->services_to_start_) { if (service->is_created()) { service->start(); // Needs to be called once per characteristic in the service } + // Remove services that have started or are starting + if (!service->is_starting() && !service->is_running()) { + this->services_to_start_[write_idx++] = service; + } } - // Remove services that have been started - this->services_to_start_.erase( - std::remove_if(this->services_to_start_.begin(), this->services_to_start_.end(), - [](BLEService *service) { return service->is_starting() || service->is_running(); }), - this->services_to_start_.end()); + this->services_to_start_.erase(this->services_to_start_.begin() + write_idx, this->services_to_start_.end()); } break; } @@ -91,8 +91,6 @@ void BLEServer::loop() { } } -bool BLEServer::is_running() { return this->parent_->is_active() && this->state_ == RUNNING; } - bool BLEServer::can_proceed() { return this->is_running() || !this->parent_->is_active(); } void BLEServer::restart_advertising_() { diff --git a/esphome/components/esp32_ble_server/ble_server.h b/esphome/components/esp32_ble_server/ble_server.h index ff7e0044e4..1b419d2ee4 100644 --- a/esphome/components/esp32_ble_server/ble_server.h +++ b/esphome/components/esp32_ble_server/ble_server.h @@ -32,7 +32,7 @@ class BLEServer : public Component, public GATTsEventHandler, public BLEStatusEv float get_setup_priority() const override; bool can_proceed() override; - bool is_running(); + ESPHOME_ALWAYS_INLINE bool is_running() { return this->parent_->is_active() && this->state_ == RUNNING; } void set_manufacturer_data(const std::vector &data) { this->manufacturer_data_ = data; From 9242895f87d8c15800319c9271e179dddc9c64a0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 16 Mar 2026 16:42:14 -1000 Subject: [PATCH 20/20] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- esphome/components/esp32_ble_server/ble_server.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/esp32_ble_server/ble_server.cpp b/esphome/components/esp32_ble_server/ble_server.cpp index a2512a8236..1dd97db486 100644 --- a/esphome/components/esp32_ble_server/ble_server.cpp +++ b/esphome/components/esp32_ble_server/ble_server.cpp @@ -43,7 +43,7 @@ void BLEServer::loop() { if (service->is_created()) { service->start(); // Needs to be called once per characteristic in the service } - // Remove services that have started or are starting + // Keep services still pending (drop those that are starting or running) if (!service->is_starting() && !service->is_running()) { this->services_to_start_[write_idx++] = service; }