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/28] [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 16e4d23d3ba..9ee3873c331 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 445a57809d2..5dd8307556c 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 c171e0537f3..b0c0d7b827f 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 bfb6ae8e130..d039c5436ea 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 a21bd647305..c790719816d 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/28] [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 5dd8307556c..a885b1add1c 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 b0c0d7b827f..c39aee7dd43 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/28] [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 16e4d23d3ba..9ee3873c331 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 d7fa6a26945..26d62cd2989 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 5b2c11cfe2c..79ba82d4388 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 bfb6ae8e130..d039c5436ea 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 a21bd647305..c790719816d 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/28] [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 26d62cd2989..159bc08a3f2 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 79ba82d4388..f6cf136e9a0 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/28] [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 159bc08a3f2..27313b95e66 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 f6cf136e9a0..4255611a8ff 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/28] 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 eba8fa407f4..f716f28e6f6 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/28] 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 f716f28e6f6..af27531f445 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/28] [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 920f772d148..a730885ea5c 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/28] [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 b4910a90de4..6efc13acd85 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 ba9bc7e4beb..6c2f7d517ee 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/28] [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 6efc13acd85..5b8ad5b358c 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 6c2f7d517ee..8b958add05b 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/28] [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 5b8ad5b358c..6a03a5fba51 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/28] [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 6a03a5fba51..2609968eab3 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/28] 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 031385c36d2..c52241e406d 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 8b958add05b..59009507076 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/28] 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 c52241e406d..fa4eb28f31e 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/28] 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 fa4eb28f31e..37d0edde682 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/28] 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 37d0edde682..430eecdf6e4 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 06d1498c47e78a9635333a109a089c58309f50c2 Mon Sep 17 00:00:00 2001 From: guillempages Date: Thu, 12 Mar 2026 15:15:20 +0100 Subject: [PATCH 17/28] [runtime_image] Update jpegdec lib version (#14726) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- .clang-tidy.hash | 2 +- esphome/components/runtime_image/__init__.py | 2 +- platformio.ini | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.clang-tidy.hash b/.clang-tidy.hash index ff25675918b..87b4ebb2c69 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -e4b9c4b54e705d3c9400e1cdda8ba0b32634780cfa5f32271832e911bdcafe7e +8e48e836c6fc196d3da000d46eb09db243b87fe33518a74e49c8e009d756074a diff --git a/esphome/components/runtime_image/__init__.py b/esphome/components/runtime_image/__init__.py index 0773a53d911..7c22bfc9d19 100644 --- a/esphome/components/runtime_image/__init__.py +++ b/esphome/components/runtime_image/__init__.py @@ -74,7 +74,7 @@ class JPEGFormat(Format): def actions(self) -> None: cg.add_define("USE_RUNTIME_IMAGE_JPEG") - cg.add_library("JPEGDEC", None, "https://github.com/bitbank2/JPEGDEC#ca1e0f2") + cg.add_library("JPEGDEC", "1.8.4", "https://github.com/bitbank2/JPEGDEC#1.8.4") class PNGFormat(Format): diff --git a/platformio.ini b/platformio.ini index deee23d049c..3c3d62ef76a 100644 --- a/platformio.ini +++ b/platformio.ini @@ -46,11 +46,11 @@ lib_deps_base = lib_deps = ${common.lib_deps_base} - esphome/noise-c@0.1.11 ; api + esphome/noise-c@0.1.11 ; api improv/Improv@1.2.4 ; improv_serial / esp32_improv kikuchan98/pngle@1.1.0 ; online_image ; Using the repository directly, otherwise ESP-IDF can't use the library - https://github.com/bitbank2/JPEGDEC.git#ca1e0f2 ; online_image + https://github.com/bitbank2/JPEGDEC.git#1.8.4 ; online_image ; This dependency is used only in unit tests. ; Must coincide with PLATFORMIO_GOOGLE_TEST_LIB in scripts/cpp_unit_test.py ; See scripts/cpp_unit_test.py and tests/components/README.md From 5d5c2723b2570d06dddab60a1152903df592d5a3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 15 Mar 2026 18:14:41 -1000 Subject: [PATCH 18/28] [fastled] Include esp_lcd IDF component for ESP32-S3 compatibility (#14839) --- esphome/components/fastled_base/__init__.py | 5 +++++ tests/components/fastled_clockless/test.esp32-s3-ard.yaml | 1 + 2 files changed, 6 insertions(+) create mode 100644 tests/components/fastled_clockless/test.esp32-s3-ard.yaml diff --git a/esphome/components/fastled_base/__init__.py b/esphome/components/fastled_base/__init__.py index 11e8423258c..c944e8a930c 100644 --- a/esphome/components/fastled_base/__init__.py +++ b/esphome/components/fastled_base/__init__.py @@ -7,6 +7,7 @@ from esphome.const import ( CONF_OUTPUT_ID, CONF_RGB_ORDER, ) +from esphome.core import CORE CODEOWNERS = ["@OttoWinter"] fastled_base_ns = cg.esphome_ns.namespace("fastled_base") @@ -41,5 +42,9 @@ async def new_fastled_light(config): cg.add(var.set_max_refresh_rate(config[CONF_MAX_REFRESH_RATE])) cg.add_library("fastled/FastLED", "3.9.16") + if CORE.is_esp32: + from esphome.components.esp32 import include_builtin_idf_component + + include_builtin_idf_component("esp_lcd") await light.register_light(var, config) return var diff --git a/tests/components/fastled_clockless/test.esp32-s3-ard.yaml b/tests/components/fastled_clockless/test.esp32-s3-ard.yaml new file mode 100644 index 00000000000..dade44d145b --- /dev/null +++ b/tests/components/fastled_clockless/test.esp32-s3-ard.yaml @@ -0,0 +1 @@ +<<: !include common.yaml From d6fba390378b6e8469254c518b3e0e5d1312026b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 15 Mar 2026 18:15:01 -1000 Subject: [PATCH 19/28] [runtime_image] Add esp-dsp dependency for JPEGDEC SIMD on ESP32 (#14840) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- esphome/components/runtime_image/__init__.py | 8 ++++++++ esphome/idf_component.yml | 2 ++ .../online_image/test.esp32-s3-ard.yaml | 19 +++++++++++++++++++ .../online_image/test.esp32-s3-idf.yaml | 19 +++++++++++++++++++ 4 files changed, 48 insertions(+) create mode 100644 tests/components/online_image/test.esp32-s3-ard.yaml create mode 100644 tests/components/online_image/test.esp32-s3-idf.yaml diff --git a/esphome/components/runtime_image/__init__.py b/esphome/components/runtime_image/__init__.py index 7c22bfc9d19..3ae35cc5f17 100644 --- a/esphome/components/runtime_image/__init__.py +++ b/esphome/components/runtime_image/__init__.py @@ -11,6 +11,7 @@ from esphome.components.image import ( ) import esphome.config_validation as cv from esphome.const import CONF_FORMAT, CONF_ID, CONF_RESIZE, CONF_TYPE +from esphome.core import CORE AUTO_LOAD = ["image"] CODEOWNERS = ["@guillempages", "@clydebarrow", "@kahrendt"] @@ -75,6 +76,13 @@ class JPEGFormat(Format): def actions(self) -> None: cg.add_define("USE_RUNTIME_IMAGE_JPEG") cg.add_library("JPEGDEC", "1.8.4", "https://github.com/bitbank2/JPEGDEC#1.8.4") + if CORE.is_esp32: + from esphome.components.esp32 import add_idf_component + + # JPEGDEC uses ESP32-S3 SIMD optimizations (guarded by board-level + # ARDUINO_ESP32S3_DEV define) that require esp-dsp headers. + # On Arduino this overwrites the stub; on IDF it adds the component. + add_idf_component(name="espressif/esp-dsp", ref="1.7.1") class PNGFormat(Format): diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index df651ae15dd..381ecdcbf6e 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -5,6 +5,8 @@ dependencies: version: 2.0.3 esphome/micro-opus: version: 0.3.5 + espressif/esp-dsp: + version: "1.7.1" espressif/esp-tflite-micro: version: 1.3.3~1 espressif/esp32-camera: diff --git a/tests/components/online_image/test.esp32-s3-ard.yaml b/tests/components/online_image/test.esp32-s3-ard.yaml new file mode 100644 index 00000000000..9116fd86e09 --- /dev/null +++ b/tests/components/online_image/test.esp32-s3-ard.yaml @@ -0,0 +1,19 @@ +packages: + spi: !include ../../test_build_components/common/spi/esp32-s3-ard.yaml + +<<: !include common.yaml + +http_request: + +display: + - platform: ili9xxx + spi_id: spi_bus + id: main_lcd + model: ili9342 + cs_pin: 20 + dc_pin: 13 + reset_pin: 21 + invert_colors: true + lambda: |- + it.fill(Color(0, 0, 0)); + it.image(0, 0, id(online_rgba_image)); diff --git a/tests/components/online_image/test.esp32-s3-idf.yaml b/tests/components/online_image/test.esp32-s3-idf.yaml new file mode 100644 index 00000000000..f219f71ee25 --- /dev/null +++ b/tests/components/online_image/test.esp32-s3-idf.yaml @@ -0,0 +1,19 @@ +packages: + spi: !include ../../test_build_components/common/spi/esp32-s3-idf.yaml + +<<: !include common.yaml + +http_request: + +display: + - platform: ili9xxx + spi_id: spi_bus + id: main_lcd + model: ili9342 + cs_pin: 20 + dc_pin: 13 + reset_pin: 21 + invert_colors: true + lambda: |- + it.fill(Color(0, 0, 0)); + it.image(0, 0, id(online_rgba_image)); From ffce637ea547698c034ab6fde3d04c3ee8fc6e41 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 16 Mar 2026 05:43:38 +0000 Subject: [PATCH 20/28] Bump aioesphomeapi from 44.5.1 to 44.5.2 (#14849) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index e634bcb1046..da95dd5a13d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ platformio==6.1.19 esptool==5.2.0 click==8.3.1 esphome-dashboard==20260210.0 -aioesphomeapi==44.5.1 +aioesphomeapi==44.5.2 zeroconf==0.148.0 puremagic==1.30 ruamel.yaml==0.19.1 # dashboard_import From b8ce907976689a5f78ca4f80d483087d001fc5d8 Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Mon, 16 Mar 2026 08:08:05 +0100 Subject: [PATCH 21/28] [ble_nus] fix uart debug (#14850) --- esphome/components/ble_nus/ble_nus.cpp | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/esphome/components/ble_nus/ble_nus.cpp b/esphome/components/ble_nus/ble_nus.cpp index d0d37dbf1cb..2f60f814718 100644 --- a/esphome/components/ble_nus/ble_nus.cpp +++ b/esphome/components/ble_nus/ble_nus.cpp @@ -67,14 +67,14 @@ bool BLENUS::read_array(uint8_t *data, size_t len) { // First, use the peek buffer if available if (this->has_peek_) { +#ifdef USE_UART_DEBUGGER + this->debug_callback_.call(uart::UART_DIRECTION_RX, this->peek_buffer_); +#endif data[0] = this->peek_buffer_; this->has_peek_ = false; data++; if (--len == 0) { // Decrement len first, then check it... -#ifdef USE_UART_DEBUGGER - this->debug_callback_.call(uart::UART_DIRECTION_RX, this->peek_buffer_); -#endif - return true; // No more to read + return true; // No more to read } } From 0c260e483e2700401724706ffd02bc038c65fc93 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 16 Mar 2026 15:39:26 -0400 Subject: [PATCH 22/28] [gpio][dallas_temp] Fix one_wire read64() and DS18S20 division by zero (#14866) --- esphome/components/dallas_temp/dallas_temp.cpp | 3 +++ esphome/components/gpio/one_wire/gpio_one_wire.cpp | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/esphome/components/dallas_temp/dallas_temp.cpp b/esphome/components/dallas_temp/dallas_temp.cpp index 13f2fa59bd7..f119e28e78e 100644 --- a/esphome/components/dallas_temp/dallas_temp.cpp +++ b/esphome/components/dallas_temp/dallas_temp.cpp @@ -136,6 +136,9 @@ bool DallasTemperatureSensor::check_scratch_pad_() { float DallasTemperatureSensor::get_temp_c_() { int16_t temp = (this->scratch_pad_[1] << 8) | this->scratch_pad_[0]; if ((this->address_ & 0xff) == DALLAS_MODEL_DS18S20) { + if (this->scratch_pad_[7] == 0) { + return NAN; + } return (temp >> 1) + (this->scratch_pad_[7] - this->scratch_pad_[6]) / float(this->scratch_pad_[7]) - 0.25; } switch (this->resolution_) { diff --git a/esphome/components/gpio/one_wire/gpio_one_wire.cpp b/esphome/components/gpio/one_wire/gpio_one_wire.cpp index 4191c45de15..4e2a306fc94 100644 --- a/esphome/components/gpio/one_wire/gpio_one_wire.cpp +++ b/esphome/components/gpio/one_wire/gpio_one_wire.cpp @@ -131,7 +131,7 @@ uint8_t IRAM_ATTR GPIOOneWireBus::read8() { uint64_t IRAM_ATTR GPIOOneWireBus::read64() { InterruptLock lock; uint64_t ret = 0; - for (uint8_t i = 0; i < 8; i++) { + for (uint8_t i = 0; i < 64; i++) { ret |= (uint64_t(this->read_bit_()) << i); } return ret; From bb0a5dc8a8c2df1217dd9cab0402b2e9c29e2b7d Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 16 Mar 2026 15:40:24 -0400 Subject: [PATCH 23/28] [lilygo_t5_47] Fix Y coordinate mapping and clamp touch point count (#14865) --- .../lilygo_t5_47/touchscreen/lilygo_t5_47_touchscreen.cpp | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/esphome/components/lilygo_t5_47/touchscreen/lilygo_t5_47_touchscreen.cpp b/esphome/components/lilygo_t5_47/touchscreen/lilygo_t5_47_touchscreen.cpp index b29e4c21540..ee6c2ee4718 100644 --- a/esphome/components/lilygo_t5_47/touchscreen/lilygo_t5_47_touchscreen.cpp +++ b/esphome/components/lilygo_t5_47/touchscreen/lilygo_t5_47_touchscreen.cpp @@ -42,7 +42,7 @@ void LilygoT547Touchscreen::setup() { this->x_raw_max_ = this->display_->get_native_width(); } if (this->y_raw_max_ == this->y_raw_min_) { - this->x_raw_max_ = this->display_->get_native_height(); + this->y_raw_max_ = this->display_->get_native_height(); } } } @@ -64,6 +64,10 @@ void LilygoT547Touchscreen::update_touches() { } point = buffer[5] & 0xF; + if (point > 2) { + ESP_LOGW(TAG, "Invalid touch point count: %d", point); + point = 2; + } if (point == 1) { err = this->write_register(TOUCH_REGISTER, READ_TOUCH, 1); From f36b0fcb61f78ab2a01cfb03c57f5b1ad75b8fdc Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 16 Mar 2026 15:42:13 -0400 Subject: [PATCH 24/28] [am43] Fix battery update throttle using wrong type (#14864) --- esphome/components/am43/sensor/am43_sensor.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/am43/sensor/am43_sensor.h b/esphome/components/am43/sensor/am43_sensor.h index 91973d8e33f..195b96a19eb 100644 --- a/esphome/components/am43/sensor/am43_sensor.h +++ b/esphome/components/am43/sensor/am43_sensor.h @@ -35,7 +35,7 @@ class Am43 : public esphome::ble_client::BLEClientNode, public PollingComponent uint8_t current_sensor_; // The AM43 often gets into a state where it spams loads of battery update // notifications. Here we will limit to no more than every 10s. - uint8_t last_battery_update_; + uint32_t last_battery_update_; }; } // namespace am43 From 9133582aa0c636bd3e81bc424b46c5248427ccb3 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 16 Mar 2026 15:44:55 -0400 Subject: [PATCH 25/28] [as3935] Fix ENERGY_MASK dropping bit 4 of lightning energy MMSB (#14861) --- esphome/components/as3935/as3935.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/as3935/as3935.h b/esphome/components/as3935/as3935.h index 5dff1cb0aef..5f46dadfa8c 100644 --- a/esphome/components/as3935/as3935.h +++ b/esphome/components/as3935/as3935.h @@ -41,7 +41,7 @@ enum AS3935RegisterMasks { INT_MASK = 0xF0, THRESH_MASK = 0x0F, R_SPIKE_MASK = 0xF0, - ENERGY_MASK = 0xF0, + ENERGY_MASK = 0xE0, CAP_MASK = 0xF0, LIGHT_MASK = 0xCF, DISTURB_MASK = 0xDF, From 0816b27398b0abb63c11d6f7a10e65ef95f6dcec Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 16 Mar 2026 15:45:16 -0400 Subject: [PATCH 26/28] [core] Support both dot and dash separators in Version.parse (#14858) --- esphome/config_validation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/config_validation.py b/esphome/config_validation.py index 1eac53e9b20..32689dab27e 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -314,7 +314,7 @@ class Version: @classmethod def parse(cls, value: str) -> Version: - match = re.match(r"^(\d+).(\d+).(\d+)-?(\w*)$", value) + match = re.match(r"^(\d+).(\d+).(\d+)[-.]?(\w*)$", value) if match is None: raise ValueError(f"Not a valid version number {value}") major = int(match[1]) From d6c67d5c357669e547f94a461614976e0d3d54cb Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 17 Mar 2026 11:45:03 +1300 Subject: [PATCH 27/28] Bump version to 2026.3.0b3 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 4ec3a24c9fe..96295b3fc8e 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2026.3.0b2 +PROJECT_NUMBER = 2026.3.0b3 # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/const.py b/esphome/const.py index 2466f2c49c0..561a27d2286 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.3.0b2" +__version__ = "2026.3.0b3" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From 273637b6d7914c6326731b53a63bad426cf9e3eb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 16 Mar 2026 16:35:32 -1000 Subject: [PATCH 28/28] 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 430eecdf6e4..69a8e0e9c86 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) {