Compare commits

..
36 changed files with 439 additions and 657 deletions
@@ -6,7 +6,6 @@
#include "esphome/components/network/util.h"
#include "esphome/core/log.h"
#include <cerrno>
#include <sys/select.h>
namespace esphome::async_tcp {
@@ -42,7 +41,15 @@ bool AsyncClient::connect(const char *host, uint16_t port) {
return false;
}
socket_->setblocking(false);
if (socket_->setblocking(false) != 0) {
// Capture before the log and close() clobber errno
const int saved_errno = errno;
ESP_LOGE(TAG, "Failed to set nonblocking: errno %d", saved_errno);
close();
if (error_cb_)
error_cb_(error_arg_, this, saved_errno);
return false;
}
int err = socket_->connect((struct sockaddr *) &addr, addrlen);
if (err == 0) {
@@ -97,45 +104,22 @@ void AsyncClient::loop() {
return;
if (connecting_) {
// For connecting, we need to check writability, not readability
// The Application's select() only monitors read FDs, so we do our own check here
// For ESP platforms lwip_select() might be faster, but this code isn't used
// on those platforms anyway. If it was, we'd fix the Application select()
// to report writability instead of doing it this way.
int fd = socket_->get_fd();
if (fd < 0) {
ESP_LOGW(TAG, "Invalid socket fd");
close();
return;
}
fd_set writefds;
FD_ZERO(&writefds);
FD_SET(fd, &writefds);
struct timeval tv = {0, 0};
int ret = select(fd + 1, nullptr, &writefds, nullptr, &tv);
if (ret > 0 && FD_ISSET(fd, &writefds)) {
int error = 0;
socklen_t len = sizeof(error);
if (socket_->getsockopt(SOL_SOCKET, SO_ERROR, &error, &len) == 0 && error == 0) {
int err = 0;
switch (socket::poll_connect(*socket_, err)) {
case socket::ConnectPollResult::CONNECT_POLL_RESULT_PENDING:
break;
case socket::ConnectPollResult::CONNECT_POLL_RESULT_CONNECTED:
connecting_ = false;
connected_ = true;
if (connect_cb_)
connect_cb_(connect_arg_, this);
} else {
ESP_LOGW(TAG, "Connection failed: %d", error);
break;
case socket::ConnectPollResult::CONNECT_POLL_RESULT_ERROR:
ESP_LOGW(TAG, "Connection failed: %d", err);
close();
if (error_cb_)
error_cb_(error_arg_, this, error);
}
} else if (ret < 0) {
const int err = errno;
ESP_LOGE(TAG, "Select error: %d", err);
close();
if (error_cb_)
error_cb_(error_arg_, this, err);
error_cb_(error_arg_, this, err);
break;
}
} else if (connected_) {
// For connected sockets, use the Application's select() results
+26 -2
View File
@@ -3,6 +3,7 @@ import logging
import esphome.codegen as cg
from esphome.components import web_server_base, wifi
from esphome.components.web_server_base import CONF_WEB_SERVER_BASE_ID
from esphome.config_helpers import filter_source_files_from_platform
import esphome.config_validation as cv
from esphome.const import (
CONF_AP,
@@ -14,6 +15,7 @@ from esphome.const import (
PLATFORM_LN882X,
PLATFORM_RP2,
PLATFORM_RTL87XX,
PlatformFramework,
)
from esphome.core import CORE, coroutine_with_priority
from esphome.coroutine import CoroPriority
@@ -74,7 +76,17 @@ def _final_validate(config: ConfigType) -> None:
"Add 'ap:' to your WiFi configuration to enable the captive portal."
)
web_server_base.consume_captive_dns_sockets(config, "captive_portal")
# Register socket needs for DNS server and additional HTTP connections
# - 1 UDP socket for DNS server
# - 3 TCP sockets for captive portal detection probes + configuration requests
# OS captive portal detection makes multiple probe requests that stay in TIME_WAIT.
# Need headroom for actual user configuration requests.
# LRU purging will reclaim idle sockets to prevent exhaustion from repeated attempts.
# The listening socket is registered by web_server_base (shared HTTP server).
from esphome.components import socket
socket.consume_sockets(3, "captive_portal")(config)
socket.consume_sockets(1, "captive_portal", socket.SocketType.UDP)(config)
FINAL_VALIDATE_SCHEMA = _final_validate
@@ -94,4 +106,16 @@ async def to_code(config: ConfigType) -> None:
if config[CONF_COMPRESSION] == "gzip":
cg.add_define("USE_CAPTIVE_PORTAL_GZIP")
web_server_base.add_captive_dns_library()
if CORE.using_arduino and (CORE.is_esp8266 or CORE.is_libretiny or CORE.is_rp2):
cg.add_library("DNSServer", None)
# Only compile the ESP-IDF DNS server when using ESP-IDF framework
FILTER_SOURCE_FILES = filter_source_files_from_platform(
{
"dns_server_esp32_idf.cpp": {
PlatformFramework.ESP32_ARDUINO,
PlatformFramework.ESP32_IDF,
},
}
)
@@ -102,7 +102,17 @@ void CaptivePortal::start() {
this->base_->add_handler_without_auth(this);
}
this->dns_.start(wifi::global_wifi_component->wifi_soft_ap_ip());
network::IPAddress ip = wifi::global_wifi_component->wifi_soft_ap_ip();
#if defined(USE_ESP32)
// Create DNS server instance for ESP-IDF
this->dns_server_ = make_unique<DNSServer>();
this->dns_server_->start(ip);
#elif defined(USE_ARDUINO)
this->dns_server_ = make_unique<DNSServer>();
this->dns_server_->setErrorReplyCode(DNSReplyCode::NoError);
this->dns_server_->start(53, ESPHOME_F("*"), ip);
#endif
this->initialized_ = true;
this->active_ = true;
@@ -1,11 +1,16 @@
#pragma once
#include "esphome/core/defines.h"
#ifdef USE_CAPTIVE_PORTAL
#include <memory>
#if defined(USE_ESP32)
#include "dns_server_esp32_idf.h"
#elif defined(USE_ARDUINO)
#include <DNSServer.h>
#endif
#include "esphome/core/component.h"
#include "esphome/core/helpers.h"
#include "esphome/core/preferences.h"
#include "esphome/components/web_server_base/web_server_base.h"
#include "esphome/components/web_server_base/captive_dns.h"
namespace esphome::captive_portal {
@@ -14,7 +19,17 @@ class CaptivePortal final : public AsyncWebHandler, public Component {
CaptivePortal(web_server_base::WebServerBase *base);
void setup() override;
void dump_config() override;
void loop() override { this->dns_.loop(); }
void loop() override {
#if defined(USE_ESP32)
if (this->dns_server_ != nullptr) {
this->dns_server_->process_next_request();
}
#elif defined(USE_ARDUINO)
if (this->dns_server_ != nullptr) {
this->dns_server_->processNextRequest();
}
#endif
}
float get_setup_priority() const override;
void start();
bool is_active() const { return this->active_; }
@@ -22,7 +37,10 @@ class CaptivePortal final : public AsyncWebHandler, public Component {
this->active_ = false;
this->disable_loop(); // Stop processing DNS requests
this->base_->deinit();
this->dns_.stop();
if (this->dns_server_ != nullptr) {
this->dns_server_->stop();
this->dns_server_ = nullptr;
}
}
bool canHandle(AsyncWebServerRequest *request) const override {
@@ -42,7 +60,9 @@ class CaptivePortal final : public AsyncWebHandler, public Component {
web_server_base::WebServerBase *base_;
bool initialized_{false};
bool active_{false};
web_server_base::CaptiveDNS dns_;
#if defined(USE_ARDUINO) || defined(USE_ESP32)
std::unique_ptr<DNSServer> dns_server_{nullptr};
#endif
};
extern CaptivePortal *global_captive_portal; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
@@ -1,5 +1,5 @@
#include "dns_server_esp32_idf.h"
#if defined(USE_ESP32) && (defined(USE_CAPTIVE_PORTAL) || defined(USE_WEBSERVER_CAPTIVE))
#ifdef USE_ESP32
#include "esphome/core/log.h"
#include "esphome/core/hal.h"
@@ -7,9 +7,9 @@
#include <lwip/sockets.h>
#include <lwip/inet.h>
namespace esphome::web_server_base {
namespace esphome::captive_portal {
static const char *const TAG = "web_server_base.dns";
static const char *const TAG = "captive_portal.dns";
// DNS constants
static constexpr uint16_t DNS_PORT = 53;
@@ -202,6 +202,6 @@ void DNSServer::process_next_request() {
}
}
} // namespace esphome::web_server_base
} // namespace esphome::captive_portal
#endif // USE_ESP32 && (USE_CAPTIVE_PORTAL || USE_WEBSERVER_CAPTIVE)
#endif // USE_ESP32
@@ -1,15 +1,11 @@
#pragma once
#include "esphome/core/defines.h"
// Small DNS server that answers every query with the access point address, so a
// phone joining the AP opens the captive portal or web_server page on its own.
// Shared by captive_portal and the web_server AP mode.
#if defined(USE_ESP32) && (defined(USE_CAPTIVE_PORTAL) || defined(USE_WEBSERVER_CAPTIVE))
#ifdef USE_ESP32
#include "esphome/core/helpers.h"
#include "esphome/components/network/ip_address.h"
#include "esphome/components/socket/socket.h"
namespace esphome::web_server_base {
namespace esphome::captive_portal {
class DNSServer {
public:
@@ -31,6 +27,6 @@ class DNSServer {
uint8_t buffer_[DNS_BUFFER_SIZE];
};
} // namespace esphome::web_server_base
} // namespace esphome::captive_portal
#endif // USE_ESP32 && (USE_CAPTIVE_PORTAL || USE_WEBSERVER_CAPTIVE)
#endif // USE_ESP32
@@ -444,7 +444,10 @@ void ESPHomeOTAComponent::handle_data_() {
tv.tv_usec = 0;
this->client_->setsockopt(SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
this->client_->setsockopt(SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv));
this->client_->setblocking(true);
if (this->client_->setblocking(true) != 0) {
this->log_socket_error_(LOG_STR("blocking"));
goto error; // NOLINT(cppcoreguidelines-avoid-goto)
}
// Acknowledge auth OK - 1 byte
this->data_write_byte_(ota::OTA_RESPONSE_AUTH_OK);
@@ -59,13 +59,15 @@ int BSDSocketImpl::close() {
int BSDSocketImpl::setblocking(bool blocking) {
int fl = ::fcntl(this->fd_, F_GETFL, 0);
if (fl < 0) {
return fl;
}
if (blocking) {
fl &= ~O_NONBLOCK;
} else {
fl |= O_NONBLOCK;
}
::fcntl(this->fd_, F_SETFL, fl);
return 0;
return ::fcntl(this->fd_, F_SETFL, fl);
}
size_t BSDSocketImpl::getpeername_to(std::span<char, SOCKADDR_STR_LEN> buf) {
+7
View File
@@ -205,6 +205,13 @@ static constexpr size_t SOCKADDR_STR_LEN = 46; // INET6_ADDRSTRLEN
static constexpr size_t SOCKADDR_STR_LEN = 16; // INET_ADDRSTRLEN
#endif
/// Outcome of polling a non-blocking connect(); see socket::poll_connect().
enum class ConnectPollResult : uint8_t {
CONNECT_POLL_RESULT_PENDING,
CONNECT_POLL_RESULT_CONNECTED,
CONNECT_POLL_RESULT_ERROR,
};
} // namespace esphome::socket
#endif
+142 -64
View File
@@ -48,8 +48,33 @@ static const char *const TAG = "socket";
#ifdef USE_ESP8266
// optimistic_yield() rate limit in microseconds of CONT time; cheap when hot.
static constexpr uint32_t ESP8266_YIELD_INTERVAL_US = 1000;
// Let SYS run so queued WiFi traffic reaches lwip; CONT and SYS are cooperative
static inline void yield_to_sys() { optimistic_yield(ESP8266_YIELD_INTERVAL_US); }
#else
static inline void yield_to_sys() {}
#endif
// errno for a failed tcp_* call
static int lwip_err_to_errno(err_t err) {
switch (err) {
case ERR_MEM:
return ENOMEM;
case ERR_BUF:
return EAGAIN; // transient, e.g. no free local port
case ERR_RTE:
return EHOSTUNREACH; // no route, e.g. no address yet
case ERR_VAL:
case ERR_ARG:
return EINVAL;
case ERR_USE:
return EADDRINUSE;
case ERR_ISCONN:
return EISCONN;
default:
return EIO;
}
}
// set to 1 to enable verbose lwip logging
#if 0 // NOLINT(readability-avoid-unconditional-preprocessor-if)
#define LWIP_LOG(msg, ...) ESP_LOGVV(TAG, "socket %p: " msg, this, ##__VA_ARGS__)
@@ -62,8 +87,8 @@ static constexpr uint32_t ESP8266_YIELD_INTERVAL_US = 1000;
// Must be called before destroying the object that tcp_arg points to —
// tcp_abort() triggers the err callback synchronously, which would
// otherwise call back into a partially-destroyed object.
// tcp_sent/tcp_poll are not cleared because this implementation
// never registers them.
// tcp_sent/tcp_poll are never registered and the connect callback cannot
// fire after abort or close, so neither is cleared.
static void pcb_detach_abort(struct tcp_pcb *pcb) {
tcp_arg(pcb, nullptr);
tcp_recv(pcb, nullptr);
@@ -76,8 +101,7 @@ static void pcb_detach_abort(struct tcp_pcb *pcb) {
// After tcp_close(), the PCB remains alive during the TCP close handshake
// (FIN_WAIT, TIME_WAIT states). Without clearing callbacks first, LWIP
// would call recv/err on a destroyed socket object, corrupting the heap.
// tcp_sent/tcp_poll are not cleared because this implementation
// never registers them.
// Callbacks are left as in pcb_detach_abort().
// Returns ERR_OK on success; on failure the PCB is aborted instead.
static err_t pcb_detach_close(struct tcp_pcb *pcb) {
tcp_arg(pcb, nullptr);
@@ -101,67 +125,51 @@ LWIPRawCommon::~LWIPRawCommon() {
}
}
bool LWIPRawCommon::sockaddr2ip_(const struct sockaddr *name, socklen_t addrlen, ip_addr_t *ip, uint16_t *port) const {
if (name == nullptr) {
errno = EINVAL;
return false;
}
#if LWIP_IPV6
if (this->family_ == AF_INET6) {
if (addrlen < sizeof(sockaddr_in6)) {
errno = EINVAL;
return false;
}
auto *addr6 = reinterpret_cast<const sockaddr_in6 *>(name);
*port = ntohs(addr6->sin6_port);
inet6_addr_to_ip6addr(ip_2_ip6(ip), &addr6->sin6_addr);
// ANY lets bind() accept both families; connect() picks the concrete type
IP_SET_TYPE_VAL(*ip, IPADDR_TYPE_ANY);
return true;
}
#endif
if (this->family_ != AF_INET || addrlen < sizeof(sockaddr_in)) {
errno = EINVAL;
return false;
}
auto *addr4 = reinterpret_cast<const sockaddr_in *>(name);
*port = ntohs(addr4->sin_port);
ip_addr_set_ip4_u32(ip, addr4->sin_addr.s_addr);
return true;
}
int LWIPRawCommon::bind(const struct sockaddr *name, socklen_t addrlen) {
LWIP_LOCK();
if (this->pcb_ == nullptr) {
errno = EBADF;
return -1;
}
if (name == nullptr) {
errno = EINVAL;
return -1;
}
ip_addr_t ip;
in_port_t port;
#if LWIP_IPV6
if (this->family_ == AF_INET) {
if (addrlen < sizeof(sockaddr_in)) {
errno = EINVAL;
return -1;
}
auto *addr4 = reinterpret_cast<const sockaddr_in *>(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<const sockaddr_in6 *>(name);
port = ntohs(addr6->sin6_port);
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;
uint16_t port;
if (!this->sockaddr2ip_(name, addrlen, &ip, &port)) {
return -1;
}
#else
if (this->family_ != AF_INET) {
errno = EINVAL;
return -1;
}
auto *addr4 = reinterpret_cast<const sockaddr_in *>(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
LWIP_LOG("tcp_bind(%p ip=%s port=%u)", this->pcb_, ipaddr_ntoa(&ip), port);
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;
errno = lwip_err_to_errno(err);
return -1;
}
return 0;
@@ -178,7 +186,7 @@ int LWIPRawCommon::close() {
this->pcb_ = nullptr;
if (err != ERR_OK) {
LWIP_LOG(" -> err %d", err);
errno = err == ERR_MEM ? ENOMEM : EIO;
errno = lwip_err_to_errno(err);
return -1;
}
return 0;
@@ -205,7 +213,7 @@ int LWIPRawCommon::shutdown(int how) {
err_t err = tcp_shutdown(this->pcb_, shut_rx, shut_tx);
if (err != ERR_OK) {
LWIP_LOG(" -> err %d", err);
errno = err == ERR_MEM ? ENOMEM : EIO;
errno = lwip_err_to_errno(err);
return -1;
}
return 0;
@@ -425,7 +433,82 @@ void LWIPRawImpl::s_err_fn(void *arg, err_t err) {
// ERR_ABRT: aborted through tcp_abort or TCP timer
auto *arg_this = reinterpret_cast<LWIPRawImpl *>(arg);
ESP_LOGVV(TAG, "socket %p: err(err=%d)", arg_this, err);
if (arg_this->connect_err_ == EINPROGRESS) {
// Refused (RST) or SYN retries exhausted; written before pcb_ so
// poll_connect() never sees a dead pcb without its reason
arg_this->connect_err_ = err == ERR_RST ? ECONNREFUSED : ETIMEDOUT;
}
arg_this->pcb_ = nullptr;
esphome::wake_loop_any_context();
}
err_t LWIPRawImpl::s_connected_fn(void *arg, struct tcp_pcb *pcb, err_t err) {
// LWIP CALLBACK, same constraints as s_err_fn; err is always ERR_OK
auto *arg_this = reinterpret_cast<LWIPRawImpl *>(arg);
arg_this->connect_err_ = EISCONN;
esphome::wake_loop_any_context();
return ERR_OK;
}
int LWIPRawImpl::connect(const struct sockaddr *addr, socklen_t addrlen) {
LWIP_LOCK();
if (this->pcb_ == nullptr) {
errno = EBADF;
return -1;
}
if (this->connect_err_ == EINPROGRESS || this->connect_err_ == EISCONN) {
errno = this->connect_err_ == EINPROGRESS ? EALREADY : EISCONN;
return -1;
}
ip_addr_t ip;
uint16_t port;
if (!this->sockaddr2ip_(addr, addrlen, &ip, &port)) {
return -1;
}
#if LWIP_IPV6
// tcp_connect needs a concrete type; a remembered IPv4 peer arrives v4-mapped
if (IP_IS_ANY_TYPE_VAL(ip)) {
if (ip6_addr_isipv4mappedipv6(ip_2_ip6(&ip))) {
unmap_ipv4_mapped_ipv6(ip_2_ip4(&ip), ip_2_ip6(&ip));
IP_SET_TYPE_VAL(ip, IPADDR_TYPE_V4);
} else {
IP_SET_TYPE_VAL(ip, IPADDR_TYPE_V6);
}
}
#endif
LWIP_LOG("tcp_connect(%p ip=%s port=%u)", this->pcb_, ipaddr_ntoa(&ip), port);
err_t err = tcp_connect(this->pcb_, &ip, port, LWIPRawImpl::s_connected_fn);
if (err != ERR_OK) {
LWIP_LOG(" -> err %d", err);
errno = lwip_err_to_errno(err);
return -1;
}
this->connect_err_ = EINPROGRESS;
errno = EINPROGRESS;
return -1;
}
ConnectPollResult LWIPRawImpl::poll_connect(int &err_out) const {
// pcb_ first; see the ordering note on the declaration
if (this->pcb_ == nullptr) {
// Only a recorded connect failure carries its own reason
const bool failed = this->connect_err_ == ECONNREFUSED || this->connect_err_ == ETIMEDOUT;
err_out = failed ? this->connect_err_ : ECONNRESET;
return ConnectPollResult::CONNECT_POLL_RESULT_ERROR;
}
switch (this->connect_err_) {
case EINPROGRESS:
yield_to_sys(); // so the SYN-ACK is processed between polls
return ConnectPollResult::CONNECT_POLL_RESULT_PENDING;
case EISCONN:
return ConnectPollResult::CONNECT_POLL_RESULT_CONNECTED;
case 0:
err_out = EINVAL; // no connect was started
return ConnectPollResult::CONNECT_POLL_RESULT_ERROR;
default:
err_out = this->connect_err_;
return ConnectPollResult::CONNECT_POLL_RESULT_ERROR;
}
}
err_t LWIPRawImpl::s_recv_fn(void *arg, struct tcp_pcb *pcb, struct pbuf *pb, err_t err) {
@@ -540,14 +623,11 @@ ssize_t LWIPRawImpl::read_locked_(void *buf, size_t len) {
}
ssize_t LWIPRawImpl::read(void *buf, size_t len) {
#ifdef USE_ESP8266
// Would block: yield to SYS so queued WiFi RX reaches lwip and this read
// may succeed. Without this, inbound segments can sit unprocessed for
// seconds while the main loop polls (CONT/SYS are cooperative on ESP8266).
// Let queued WiFi RX reach lwip first; otherwise inbound segments can
// sit unprocessed for seconds while the main loop polls
if (this->waiting_for_data_()) {
optimistic_yield(ESP8266_YIELD_INTERVAL_US);
yield_to_sys();
}
#endif
// See waiting_for_data_() for safety of unlocked reads.
if (this->recv_timeout_cs_ > 0 && this->waiting_for_data_()) {
this->wait_for_data_();
@@ -636,12 +716,10 @@ int LWIPRawImpl::internal_output_() {
return -1;
}
}
#ifdef USE_ESP8266
// Flushed: yield to SYS so the queued segments reach the WiFi driver
// instead of waiting seconds for an unrelated SYS slot. Callers only get
// here after a successful tcp_write, so idle paths never yield.
optimistic_yield(ESP8266_YIELD_INTERVAL_US);
#endif
yield_to_sys();
return 0;
}
@@ -50,6 +50,8 @@ class LWIPRawCommon {
protected:
int ip2sockaddr_(ip_addr_t *ip, uint16_t port, struct sockaddr *name, socklen_t *addrlen);
/// sockaddr of this socket's family to lwip address and port; false with errno on mismatch
bool sockaddr2ip_(const struct sockaddr *name, socklen_t addrlen, ip_addr_t *ip, uint16_t *port) const;
// Member ordering optimized to minimize padding on 32-bit systems
struct tcp_pcb *pcb_;
@@ -58,7 +60,14 @@ class LWIPRawCommon {
bool nodelay_ = false;
sa_family_t family_ = 0;
uint8_t recv_timeout_cs_ = 0; // SO_RCVTIMEO in centiseconds (0 = no timeout, max 2.55s)
// 0 before connect(), EINPROGRESS while pending, EISCONN once established,
// else the failure errno the callbacks recorded; fills the padding byte
uint8_t connect_err_ = 0;
static_assert(EINPROGRESS < 256 && EISCONN < 256 && ECONNREFUSED < 256 && ECONNRESET < 256 && ETIMEDOUT < 256,
"connect_err_ stores errno values in a byte");
};
// The connect state must stay in the padding so no socket pays RAM for it
static_assert(sizeof(LWIPRawCommon) == sizeof(struct tcp_pcb *) + 4, "LWIPRawCommon grew past one word of flags");
/// Connected socket implementation for LWIP raw TCP.
/// No virtual methods — callers always use the concrete type.
@@ -83,6 +92,12 @@ class LWIPRawImpl : public LWIPRawCommon {
errno = EOPNOTSUPP;
return -1;
}
/// Non-blocking: returns -1/EINPROGRESS once the SYN is queued, see poll_connect().
/// addr must match the socket family; an IPv4 peer on AF_INET6 arrives v4-mapped.
int connect(const struct sockaddr *addr, socklen_t addrlen);
// Unlocked like ready(): the callbacks write the error byte before pcb_,
// so a torn read only costs one extra poll
ConnectPollResult poll_connect(int &err_out) const;
ssize_t read(void *buf, size_t len);
ssize_t readv(const struct iovec *iov, int iovcnt);
ssize_t recvfrom(void *, size_t, sockaddr *, socklen_t *) {
@@ -120,6 +135,7 @@ class LWIPRawImpl : public LWIPRawCommon {
static void s_err_fn(void *arg, err_t err);
static err_t s_recv_fn(void *arg, struct tcp_pcb *pcb, struct pbuf *pb, err_t err);
static err_t s_connected_fn(void *arg, struct tcp_pcb *pcb, err_t err);
protected:
// True when the socket could receive data but none has arrived yet.
@@ -137,6 +153,9 @@ class LWIPRawImpl : public LWIPRawCommon {
size_t rx_buf_offset_ = 0;
bool rx_closed_ = false;
};
// rx_buf_, rx_buf_offset_, then rx_closed_ padded to a word
static_assert(sizeof(LWIPRawImpl) == sizeof(LWIPRawCommon) + sizeof(pbuf *) + sizeof(size_t) + 4,
"LWIPRawImpl layout changed");
/// Listening socket implementation for LWIP raw TCP.
/// Separate from LWIPRawImpl — no virtual dispatch needed.
@@ -49,13 +49,15 @@ int LwIPSocketImpl::close() {
int LwIPSocketImpl::setblocking(bool blocking) {
int fl = lwip_fcntl(this->fd_, F_GETFL, 0);
if (fl < 0) {
return fl;
}
if (blocking) {
fl &= ~O_NONBLOCK;
} else {
fl |= O_NONBLOCK;
}
lwip_fcntl(this->fd_, F_SETFL, fl);
return 0;
return lwip_fcntl(this->fd_, F_SETFL, fl);
}
size_t LwIPSocketImpl::getpeername_to(std::span<char, SOCKADDR_STR_LEN> buf) {
+54 -2
View File
@@ -2,6 +2,9 @@
#if defined(USE_SOCKET_IMPL_LWIP_TCP) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) || defined(USE_SOCKET_IMPL_BSD_SOCKETS)
#include <cerrno>
#include <cstring>
#ifdef USE_SOCKET_IMPL_BSD_SOCKETS
#include <sys/select.h>
#endif
#include <string>
#include "esphome/core/log.h"
#include "esphome/core/application.h"
@@ -165,7 +168,10 @@ socklen_t set_sockaddr(struct sockaddr *addr, socklen_t addrlen, const char *ip_
#else
// Use LWIP-specific functions
ip6_addr_t ip6;
inet6_aton(ip_address, &ip6);
if (inet6_aton(ip_address, &ip6) == 0) {
errno = EINVAL;
return 0;
}
memcpy(server->sin6_addr.un.u32_addr, ip6.addr, sizeof(ip6.addr));
#endif
return sizeof(sockaddr_in6);
@@ -185,12 +191,58 @@ socklen_t set_sockaddr(struct sockaddr *addr, socklen_t addrlen, const char *ip_
return 0;
}
#else
server->sin_addr.s_addr = inet_addr(ip_address);
// Unlike inet_addr(), inet_aton() can signal failure while still
// accepting the broadcast address 255.255.255.255
if (inet_aton(ip_address, &server->sin_addr) == 0) {
errno = EINVAL;
return 0;
}
#endif
server->sin_port = htons(port);
return sizeof(sockaddr_in);
}
#if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS)
ConnectPollResult poll_connect(Socket &sock, int &err_out) {
int fd = sock.get_fd();
if (fd < 0 || fd >= FD_SETSIZE) {
// FD_SET on either is undefined behavior
err_out = EBADF;
return ConnectPollResult::CONNECT_POLL_RESULT_ERROR;
}
// Connect completion is a write event; the main loop only selects on reads
fd_set writefds;
FD_ZERO(&writefds);
FD_SET(fd, &writefds);
struct timeval tv = {0, 0};
#ifdef USE_SOCKET_IMPL_LWIP_SOCKETS
// LWIP_COMPAT_SOCKETS may be off (LibreTiny), so use the lwip symbol directly
int ret = lwip_select(fd + 1, nullptr, &writefds, nullptr, &tv);
#else
// Global-scope select: the entity namespace esphome::select shadows it here
int ret = ::select(fd + 1, nullptr, &writefds, nullptr, &tv);
#endif
if (ret < 0) {
err_out = errno;
return ConnectPollResult::CONNECT_POLL_RESULT_ERROR;
}
if (ret == 0) {
return ConnectPollResult::CONNECT_POLL_RESULT_PENDING;
}
int error = 0;
socklen_t len = sizeof(error);
if (sock.getsockopt(SOL_SOCKET, SO_ERROR, &error, &len) != 0) {
err_out = errno;
return ConnectPollResult::CONNECT_POLL_RESULT_ERROR;
}
if (error != 0) {
err_out = error;
return ConnectPollResult::CONNECT_POLL_RESULT_ERROR;
}
return ConnectPollResult::CONNECT_POLL_RESULT_CONNECTED;
}
#endif
socklen_t set_sockaddr_any(struct sockaddr *addr, socklen_t addrlen, uint16_t port) {
#if USE_NETWORK_IPV6
if (addrlen < sizeof(sockaddr_in6)) {
+8
View File
@@ -145,6 +145,14 @@ inline socklen_t set_sockaddr(struct sockaddr *addr, socklen_t addrlen, const st
/// Set a sockaddr to the any address and specified port for the IP version used by socket_ip().
socklen_t set_sockaddr_any(struct sockaddr *addr, socklen_t addrlen, uint16_t port);
/// Poll a connect() that returned EINPROGRESS. On error, err_out is SO_ERROR (or
/// errno) on fd implementations and the failure the callbacks recorded on raw lwip.
#ifdef USE_SOCKET_IMPL_LWIP_TCP
inline ConnectPollResult poll_connect(Socket &sock, int &err_out) { return sock.poll_connect(err_out); }
#else
ConnectPollResult poll_connect(Socket &sock, int &err_out);
#endif
/// Format sockaddr into caller-provided buffer, returns length written (excluding null)
size_t format_sockaddr_to(const struct sockaddr *addr_ptr, socklen_t len, std::span<char, SOCKADDR_STR_LEN> buf);
+11 -2
View File
@@ -13,7 +13,12 @@ void UDPComponent::setup() {
#if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS)
for (const auto &address : this->addresses_) {
struct sockaddr saddr {};
socket::set_sockaddr(&saddr, sizeof(saddr), address, this->broadcast_port_);
if (socket::set_sockaddr(&saddr, sizeof(saddr), address, this->broadcast_port_) == 0) {
ESP_LOGW(TAG, "Invalid address %s", address);
// A dropped address silently receives nothing; surface the misconfiguration
this->status_set_warning(LOG_STR("invalid address"));
continue;
}
this->sockaddrs_.push_back(saddr);
}
// set up broadcast socket
@@ -94,7 +99,11 @@ void UDPComponent::setup() {
// 8266 and RP2040 `Duino
for (const auto &address : this->addresses_) {
auto ipaddr = IPAddress();
ipaddr.fromString(address);
if (!ipaddr.fromString(address)) {
ESP_LOGW(TAG, "Invalid address %s", address);
this->status_set_warning(LOG_STR("invalid address"));
continue;
}
this->ipaddrs_.push_back(ipaddr);
}
if (this->should_listen_)
@@ -34,6 +34,10 @@ void WakeOnLanButton::press_action() {
struct sockaddr_storage saddr {};
auto addr_len =
socket::set_sockaddr(reinterpret_cast<sockaddr *>(&saddr), sizeof(saddr), "255.255.255.255", this->port_);
if (addr_len == 0) {
ESP_LOGW(TAG, "Invalid broadcast address");
return;
}
uint8_t buffer[6 + sizeof this->macaddr_ * 16];
memcpy(buffer, PREFIX, sizeof(PREFIX));
for (size_t i = 0; i != 16; i++) {
+5 -116
View File
@@ -12,7 +12,6 @@ from esphome.components.logger import request_log_listener
from esphome.components.web_server_base import CONF_WEB_SERVER_BASE_ID
import esphome.config_validation as cv
from esphome.const import (
CONF_AP,
CONF_AUTH,
CONF_COMPRESSION,
CONF_CSS_INCLUDE,
@@ -24,19 +23,15 @@ from esphome.const import (
CONF_JS_URL,
CONF_LOCAL,
CONF_LOG,
CONF_MANUAL_IP,
CONF_NAME,
CONF_NETWORKS,
CONF_OTA,
CONF_PASSWORD,
CONF_PORT,
CONF_STATIC_IP,
CONF_TYPE,
CONF_USERNAME,
CONF_VERSION,
CONF_WEB_SERVER,
CONF_WEB_SERVER_ID,
CONF_WIFI,
PLATFORM_BK72XX,
PLATFORM_ESP32,
PLATFORM_ESP8266,
@@ -51,23 +46,7 @@ from esphome.types import ConfigType
_LOGGER = logging.getLogger(__name__)
def AUTO_LOAD() -> list[str]:
# No config parameter on purpose: that would make this a late (dynamic) auto-load and
# ota.web_server's dependency on web_server_base would not be satisfied in time.
auto_load = ["json", "web_server_base"]
# The AP mode DNS server (web_server_base/dns_server_esp32_idf) uses socket; only
# configs with a WiFi access point can end up in AP mode. CORE.raw_config is set
# after package merging, so a wifi block from a package is visible here.
wifi = CORE.raw_config.get(CONF_WIFI) if CORE.raw_config else None
if (
CORE.is_esp32
and wifi is not None
and (not isinstance(wifi, dict) or CONF_AP in wifi)
):
auto_load.append("socket")
return auto_load
AUTO_LOAD = ["json", "web_server_base"]
AUTH_TYPE_BASIC = "basic"
AUTH_TYPE_DIGEST = "digest"
@@ -226,6 +205,9 @@ def _final_validate_sorting(config: ConfigType) -> None:
)
FINAL_VALIDATE_SCHEMA = _final_validate_sorting
def _consume_web_server_sockets(config: ConfigType) -> ConfigType:
"""Register socket needs for web_server component."""
from esphome.components import socket
@@ -352,95 +334,6 @@ async def add_entity_config(entity: MockObj, config: ConfigType) -> None:
)
def wifi_is_ap_only(wifi_config: ConfigType | None) -> bool:
"""AP only: an access point and no network to join, so the device is only ever reached
through its own AP."""
return (
wifi_config is not None
and CONF_AP in wifi_config
and not wifi_config.get(CONF_NETWORKS)
)
def serve_local(config: ConfigType, wifi_config: ConfigType | None) -> bool:
"""Embed the interface unless ``local:`` says otherwise; AP only WiFi has no internet
for the hosted page. Version 1 has no local mode."""
if (local := config.get(CONF_LOCAL)) is not None:
return local
return config[CONF_VERSION] != 1 and wifi_is_ap_only(wifi_config)
def serve_captive(config: ConfigType, full_config: ConfigType) -> bool:
"""web_server runs its own captive portal while the AP is up: embedded interface plus
an access point, unless captive_portal (which owns that role) is configured. Only on
port 80: the OS captive portal probes and the DHCP portal URI always use port 80, so
a portal on another port could never be discovered."""
wifi_config = full_config.get(CONF_WIFI)
return (
"captive_portal" not in full_config
and config[CONF_PORT] == 80
and wifi_config is not None
and CONF_AP in wifi_config
and serve_local(config, wifi_config)
)
def _final_validate_ap_mode(config: ConfigType) -> None:
full_config = fv.full_config.get()
wifi_config = full_config.get(CONF_WIFI)
captive = serve_captive(config, full_config)
local = serve_local(config, wifi_config)
if captive:
web_server_base.consume_captive_dns_sockets(config, "web_server")
# Surface behavior that the config does not spell out.
if local and CONF_LOCAL not in config:
_LOGGER.info(
"WiFi is AP only: embedding the web interface in the firmware "
"(local: true, roughly 13 KB of flash for version 2, 78 KB for version 3)%s. "
"Set 'local: false' to load it from the internet instead.",
" and serving it as a captive portal on the access point"
if captive
else "",
)
elif captive:
_LOGGER.info(
"web_server will act as a captive portal while the %saccess point is active.",
"" if wifi_is_ap_only(wifi_config) else "fallback ",
)
if not wifi_is_ap_only(wifi_config):
return
if not local:
_LOGGER.warning(
"WiFi is AP only and the web_server interface is loaded from the internet, "
"which browsers on the access point usually cannot reach; the page stays "
"blank. %s so the interface is embedded in the firmware.",
"Remove 'local: false'"
if config.get(CONF_LOCAL) is False
else "Migrate to version 2 or 3",
)
elif config[CONF_PORT] != 80:
ap_ip = "192.168.4.1"
if (manual_ip := wifi_config[CONF_AP].get(CONF_MANUAL_IP)) is not None:
ap_ip = str(manual_ip[CONF_STATIC_IP])
_LOGGER.warning(
"WiFi is AP only and web_server uses port %d. The interface cannot open "
"automatically on the access point (captive portal detection only works on "
"port 80); open http://%s:%d/ manually, or remove 'port:' to use 80.",
config[CONF_PORT],
ap_ip,
config[CONF_PORT],
)
def _final_validate(config: ConfigType) -> None:
# Called one after the other rather than via cv.All: these return None.
_final_validate_sorting(config)
_final_validate_ap_mode(config)
FINAL_VALIDATE_SCHEMA = _final_validate
def build_index_html(config: ConfigType) -> str:
html = "<!DOCTYPE html><html><head><meta charset=UTF-8><link rel=icon href=data:>"
css_include = config.get(CONF_CSS_INCLUDE)
@@ -541,12 +434,8 @@ async def to_code(config: ConfigType) -> None:
with path.open(encoding="utf-8") as js_file:
add_resource_as_progmem("JS_INCLUDE", js_file.read())
cg.add(var.set_include_internal(config[CONF_INCLUDE_INTERNAL]))
if serve_local(config, CORE.config.get(CONF_WIFI)):
if CONF_LOCAL in config and config[CONF_LOCAL]:
cg.add_define("USE_WEBSERVER_LOCAL")
if serve_captive(config, CORE.config):
# AP mode: DNS server plus redirect of unknown URLs so phones open the interface
cg.add_define("USE_WEBSERVER_CAPTIVE")
web_server_base.add_captive_dns_library()
if config[CONF_COMPRESSION] == "gzip":
cg.add_define("USE_WEBSERVER_GZIP")
+3 -56
View File
@@ -44,10 +44,6 @@
#include "esphome/components/radio_frequency/radio_frequency.h"
#endif
#ifdef USE_WEBSERVER_CAPTIVE
#include "esphome/components/wifi/wifi_component.h"
#endif
#ifdef USE_WEBSERVER_LOCAL
#if USE_WEBSERVER_VERSION == 2
#include "server_index_v2.h"
@@ -338,15 +334,7 @@ void DeferredUpdateEventSourceList::on_client_disconnect_(DeferredUpdateEventSou
}
#endif
#ifdef USE_WEBSERVER_CAPTIVE
WebServer *global_web_server = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
#endif
WebServer::WebServer(web_server_base::WebServerBase *base) : base_(base) {
#ifdef USE_WEBSERVER_CAPTIVE
global_web_server = this;
#endif
}
WebServer::WebServer(web_server_base::WebServerBase *base) : base_(base) {}
#ifdef USE_WEBSERVER_CSS_INCLUDE
void WebServer::set_css_include(const char *css_include) { this->css_include_ = css_include; }
@@ -392,11 +380,6 @@ void WebServer::setup() {
this->base_->add_handler(&this->events_);
#endif
this->base_->add_handler(this);
#ifdef USE_WEBSERVER_CAPTIVE
// Not-found fallback (outside the auth middleware): the OS captive portal probes hit
// arbitrary URLs and must get the redirect without credentials.
this->base_->get_server()->onNotFound([this](AsyncWebServerRequest *request) { this->handle_not_found_(request); });
#endif
// OTA is now handled by the web_server OTA platform
@@ -412,52 +395,16 @@ void WebServer::setup() {
});
}
void WebServer::loop() {
bool keep_looping = this->events_.loop();
#ifdef USE_WEBSERVER_CAPTIVE
this->dns_.loop();
keep_looping |= this->dns_.is_running();
#endif
// No SSE clients connected (and no captive DNS to serve); stop looping until a new client connects via
// No SSE clients connected; stop looping until a new client connects via
// enable_loop_soon_any_context(). This is safe because:
// - set_interval/set_timeout/defer run via the Scheduler, independent of loop()
// - deferrable_send_state early-outs when no clients are connected
// - try_send_nodefer (log, ping) iterates sessions which are empty
// - REST API handlers use defer() which runs via the Scheduler
if (!keep_looping)
if (!this->events_.loop())
this->disable_loop();
}
#ifdef USE_WEBSERVER_CAPTIVE
void WebServer::start_captive() {
// CaptiveDNS::start() no-ops too; this guard just avoids repeating the log and enable_loop
if (this->dns_.is_running())
return;
network::IPAddress ip = wifi::global_wifi_component->wifi_soft_ap_ip();
this->dns_.start(ip);
this->enable_loop();
char ip_buf[network::IP_ADDRESS_BUFFER_SIZE];
ESP_LOGI(TAG, "AP mode: serving the web interface as captive portal at http://%s/", ip.str_to(ip_buf));
}
void WebServer::end_captive() { this->dns_.stop(); }
void WebServer::handle_not_found_(AsyncWebServerRequest *request) {
// OS captive portal probe (or any other unknown page) while the AP is up: send the browser
// to the real page. A redirect rather than the page itself, because the interface resolves
// its /events and REST paths relative to the page URL.
if (this->dns_.is_running() && request->method() == HTTP_GET) {
// Captive mode requires port 80 (enforced at validation), so no port suffix is needed.
char location[7 + network::IP_ADDRESS_BUFFER_SIZE + 1];
size_t pos = buf_append_str(location, sizeof(location), 0, "http://");
wifi::global_wifi_component->wifi_soft_ap_ip().str_to(location + pos);
buf_append_str(location, sizeof(location), strlen(location), "/");
request->redirect(location);
return;
}
request->send(404);
}
#endif
#ifdef USE_LOGGER
void WebServer::on_log(uint8_t level, const char *tag, const char *message, size_t message_len) {
(void) level;
@@ -4,9 +4,6 @@
#include "esphome/components/json/json_util.h"
#include "esphome/components/web_server_base/web_server_base.h"
#ifdef USE_WEBSERVER_CAPTIVE
#include "esphome/components/web_server_base/captive_dns.h"
#endif
#ifdef USE_WEBSERVER
#include "esphome/core/component.h"
#include "esphome/core/controller.h"
@@ -279,18 +276,6 @@ class WebServer final : public Controller, public Component, public AsyncWebHand
/// Handle an index request under '/'.
void handle_index_request(AsyncWebServerRequest *request);
#ifdef USE_WEBSERVER_CAPTIVE
/** AP mode: run a DNS server that answers every name with the AP address and redirect any
* unknown URL to the interface, so a phone joining the AP opens it through the OS captive
* portal check. Started and ended by the wifi component with the access point. start may run
* before setup() (wifi sets up first): safe because enable_loop() is a no-op before setup;
* nothing but the DNS server may be touched, in particular not base_ or the handlers.
*/
void start_captive();
void end_captive();
bool is_captive() const { return this->dns_.is_running(); }
#endif
/// Return the webserver configuration as JSON.
json::SerializationBuffer<> get_config_json();
@@ -612,10 +597,6 @@ class WebServer final : public Controller, public Component, public AsyncWebHand
#elif USE_ARDUINO
DeferredUpdateEventSourceList events_;
#endif
#ifdef USE_WEBSERVER_CAPTIVE
void handle_not_found_(AsyncWebServerRequest *request);
web_server_base::CaptiveDNS dns_;
#endif
#if USE_WEBSERVER_VERSION == 1
const char *css_url_{nullptr};
@@ -715,9 +696,5 @@ class WebServer final : public Controller, public Component, public AsyncWebHand
#endif
};
#ifdef USE_WEBSERVER_CAPTIVE
extern WebServer *global_web_server; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
#endif
} // namespace esphome::web_server
#endif
+1 -30
View File
@@ -1,9 +1,8 @@
from pathlib import Path
import esphome.codegen as cg
from esphome.config_helpers import filter_source_files_from_platform
import esphome.config_validation as cv
from esphome.const import CONF_ID, PlatformFramework
from esphome.const import CONF_ID
from esphome.core import CORE, coroutine_with_priority
from esphome.coroutine import CoroPriority
from esphome.helpers import copy_file_if_changed
@@ -27,22 +26,6 @@ WebServerBase = web_server_base_ns.class_("WebServerBase")
CONF_WEB_SERVER_BASE_ID = "web_server_base_id"
def consume_captive_dns_sockets(config: ConfigType, name: str) -> None:
"""Register the sockets a captive portal needs on top of the shared HTTP server:
1 UDP socket for the DNS server and 3 TCP sockets for the OS captive portal probes,
which make several requests that linger in TIME_WAIT."""
from esphome.components import socket
socket.consume_sockets(3, name)(config)
socket.consume_sockets(1, name, socket.SocketType.UDP)(config)
def add_captive_dns_library() -> None:
"""Pull in the Arduino DNSServer library used by CaptiveDNS off ESP32."""
if CORE.using_arduino and (CORE.is_esp8266 or CORE.is_libretiny or CORE.is_rp2):
cg.add_library("DNSServer", None)
def _consume_web_server_base_sockets(config: ConfigType) -> ConfigType:
"""Register the shared listening socket for the HTTP server.
@@ -98,15 +81,3 @@ async def to_code(config: ConfigType) -> None:
cg.add_platformio_option("extra_scripts", ["pre:fix_rp2040_hash.py"])
# https://github.com/ESP32Async/ESPAsyncWebServer/blob/main/library.json
cg.add_library("ESP32Async/ESPAsyncWebServer", "3.9.6")
# The DNS server used for captive portals on ESP32; other platforms use the Arduino
# DNSServer library. Its source is also guarded by USE_CAPTIVE_PORTAL / USE_WEBSERVER_CAPTIVE.
FILTER_SOURCE_FILES = filter_source_files_from_platform(
{
"dns_server_esp32_idf.cpp": {
PlatformFramework.ESP32_ARDUINO,
PlatformFramework.ESP32_IDF,
},
}
)
@@ -1,58 +0,0 @@
#pragma once
#include "esphome/core/defines.h"
// DNS server that answers every name with the access point address, so a phone joining the
// AP runs its captive portal check against the device. Shared by captive_portal and the
// web_server AP mode; hides the ESP32 (own implementation) vs Arduino (DNSServer library) split.
#if defined(USE_CAPTIVE_PORTAL) || defined(USE_WEBSERVER_CAPTIVE)
#include <memory>
#include "esphome/components/network/ip_address.h"
#include "esphome/core/helpers.h"
#include "esphome/core/progmem.h"
#if defined(USE_ESP32)
#include "dns_server_esp32_idf.h"
#elif defined(USE_ARDUINO)
#include <DNSServer.h>
#endif
namespace esphome::web_server_base {
// The server object only exists while running, so an idle owner (AP not up) pays one pointer.
class CaptiveDNS {
public:
void start(const network::IPAddress &ip) {
if (this->dns_server_ != nullptr)
return;
this->dns_server_ = make_unique<DNSServer>();
#if defined(USE_ESP32)
this->dns_server_->start(ip);
#elif defined(USE_ARDUINO)
this->dns_server_->setErrorReplyCode(DNSReplyCode::NoError);
this->dns_server_->start(53, ESPHOME_F("*"), ip);
#endif
}
void stop() {
if (this->dns_server_ == nullptr)
return;
this->dns_server_->stop();
this->dns_server_ = nullptr;
}
/// Answer one pending query; call from the owner's loop() while running.
void loop() {
if (this->dns_server_ == nullptr)
return;
#if defined(USE_ESP32)
this->dns_server_->process_next_request();
#elif defined(USE_ARDUINO)
this->dns_server_->processNextRequest();
#endif
}
bool is_running() const { return this->dns_server_ != nullptr; }
protected:
// ESP32: web_server_base::DNSServer from dns_server_esp32_idf.h; Arduino: the library class.
std::unique_ptr<DNSServer> dns_server_;
};
} // namespace esphome::web_server_base
#endif // USE_CAPTIVE_PORTAL || USE_WEBSERVER_CAPTIVE
@@ -325,9 +325,9 @@ StringRef AsyncWebServerRequest::url_to(std::span<char, URL_BUF_SIZE> buffer) co
return StringRef(buffer.data(), decoded_len);
}
void AsyncWebServerRequest::redirect(const char *url) {
void AsyncWebServerRequest::redirect(const std::string &url) {
httpd_resp_set_status(*this, "302 Found");
httpd_resp_set_hdr(*this, "Location", url);
httpd_resp_set_hdr(*this, "Location", url.c_str());
httpd_resp_set_hdr(*this, "Connection", "close");
httpd_resp_send(*this, nullptr, 0);
}
@@ -126,8 +126,7 @@ class AsyncWebServerRequest {
void requestAuthentication() const;
#endif
void redirect(const char *url);
void redirect(const std::string &url) { this->redirect(url.c_str()); }
void redirect(const std::string &url);
inline void ESPHOME_ALWAYS_INLINE send(AsyncWebServerResponse *response) {
httpd_resp_send(*this, response->get_content_data(), response->get_content_size());
+16 -47
View File
@@ -36,9 +36,6 @@
#ifdef USE_CAPTIVE_PORTAL
#include "esphome/components/captive_portal/captive_portal.h"
#endif
#ifdef USE_WEBSERVER_CAPTIVE
#include "esphome/components/web_server/web_server.h"
#endif
#ifdef USE_IMPROV
#include "esphome/components/esp32_improv/esp32_improv_component.h"
@@ -744,9 +741,9 @@ void WiFiComponent::start() {
if (captive_portal::global_captive_portal != nullptr) {
this->wifi_sta_pre_setup_();
this->start_scanning();
captive_portal::global_captive_portal->start();
}
#endif
this->start_ap_portal_();
#endif // USE_WIFI_AP
}
#ifdef USE_IMPROV
@@ -807,8 +804,8 @@ void WiFiComponent::loop() {
this->check_connecting_finished(now);
break;
}
// Use longer cooldown when a portal/improv is active to avoid disrupting a user on the AP
bool portal_active = this->is_ap_portal_active_() || this->is_esp32_improv_active_();
// Use longer cooldown when captive portal/improv is active to avoid disrupting user config
bool portal_active = this->is_captive_portal_active_() || this->is_esp32_improv_active_();
uint32_t cooldown_duration = portal_active ? WIFI_COOLDOWN_WITH_AP_ACTIVE_MS : WIFI_COOLDOWN_DURATION_MS;
if (now - this->action_started_ > cooldown_duration) {
// After cooldown we either restarted the adapter because of
@@ -886,11 +883,13 @@ void WiFiComponent::loop() {
ESP_LOGI(TAG, "Starting fallback AP");
this->setup_ap_config_();
#ifdef USE_CAPTIVE_PORTAL
// Reset so we force one full scan after captive portal starts
// (previous scans were filtered because captive portal wasn't active yet)
this->has_completed_scan_after_captive_portal_start_ = false;
if (captive_portal::global_captive_portal != nullptr) {
// Reset so we force one full scan after captive portal starts
// (previous scans were filtered because captive portal wasn't active yet)
this->has_completed_scan_after_captive_portal_start_ = false;
captive_portal::global_captive_portal->start();
}
#endif
this->start_ap_portal_();
}
}
#endif // USE_WIFI_AP
@@ -1637,8 +1636,10 @@ void WiFiComponent::check_connecting_finished(uint32_t now) {
this->retry_phase_ = WiFiRetryPhase::INITIAL_CONNECT;
this->num_retried_ = 0;
if (this->has_ap()) {
#ifdef USE_WIFI_AP
this->end_ap_portal_();
#ifdef USE_CAPTIVE_PORTAL
if (this->is_captive_portal_active_()) {
captive_portal::global_captive_portal->end();
}
#endif
ESP_LOGD(TAG, "Disabling AP");
this->wifi_mode_({}, false);
@@ -1965,10 +1966,10 @@ bool WiFiComponent::transition_to_phase_(WiFiRetryPhase new_phase) {
break;
case WiFiRetryPhase::RESTARTING_ADAPTER:
// Skip actual adapter restart if a portal/improv is active
// Skip actual adapter restart if captive portal/improv is active
// This allows state machine to reset num_retried_ and trigger fresh scan
// without disrupting the portal/improv connection
if (!this->is_ap_portal_active_() && !this->is_esp32_improv_active_()) {
// without disrupting the captive portal/improv connection
if (!this->is_captive_portal_active_() && !this->is_esp32_improv_active_()) {
this->restart_adapter();
} else {
// Even when skipping full restart, disconnect to clear driver state
@@ -2227,38 +2228,6 @@ bool WiFiComponent::is_captive_portal_active_() {
return false;
#endif
}
bool WiFiComponent::is_ap_portal_active_() {
#ifdef USE_WEBSERVER_CAPTIVE
if (web_server::global_web_server->is_captive())
return true;
#endif
return this->is_captive_portal_active_();
}
#ifdef USE_WIFI_AP
// global_web_server needs no null check: codegen always instantiates WebServer when
// USE_WEBSERVER_CAPTIVE is defined, and the constructor assigns the global.
void WiFiComponent::start_ap_portal_() {
#ifdef USE_CAPTIVE_PORTAL
if (captive_portal::global_captive_portal != nullptr)
captive_portal::global_captive_portal->start();
#endif
#ifdef USE_WEBSERVER_CAPTIVE
web_server::global_web_server->start_captive();
#endif
}
void WiFiComponent::end_ap_portal_() {
#ifdef USE_CAPTIVE_PORTAL
if (this->is_captive_portal_active_())
captive_portal::global_captive_portal->end();
#endif
#ifdef USE_WEBSERVER_CAPTIVE
web_server::global_web_server->end_captive();
#endif
}
#endif // USE_WIFI_AP
bool WiFiComponent::is_esp32_improv_active_() {
#ifdef USE_IMPROV
return esp32_improv::global_improv_component != nullptr && esp32_improv::global_improv_component->is_active();
-6
View File
@@ -797,12 +797,6 @@ class WiFiComponent final : public Component {
network::IPAddress wifi_dns_ip_(int num);
bool is_captive_portal_active_();
/// captive_portal or the web_server AP mode is serving a user on the access point
bool is_ap_portal_active_();
#ifdef USE_WIFI_AP
void start_ap_portal_();
void end_ap_portal_();
#endif
bool is_esp32_improv_active_();
#ifdef USE_WIFI_FAST_CONNECT
@@ -1130,16 +1130,10 @@ bool WiFiComponent::wifi_ap_ip_config_(const optional<ManualIP> &manual_ip) {
return false;
}
#if (defined(USE_CAPTIVE_PORTAL) || defined(USE_WEBSERVER_CAPTIVE)) && ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 4, 0)
// Configure DHCP Option 114 (Captive Portal URI) if captive portal or the web_server AP
// mode is enabled. This provides a standards-compliant way for clients to discover the portal
#ifdef USE_WEBSERVER_CAPTIVE
// web_server AP mode always serves the portal when compiled in
const bool has_portal = true;
#else
const bool has_portal = captive_portal::global_captive_portal != nullptr;
#endif
if (has_portal) {
#if defined(USE_CAPTIVE_PORTAL) && ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 4, 0)
// Configure DHCP Option 114 (Captive Portal URI) if captive portal is enabled
// This provides a standards-compliant way for clients to discover the captive portal
if (captive_portal::global_captive_portal != nullptr) {
// Buffer must be static - dhcps_set_option_info stores pointer, doesn't copy
static char captive_portal_uri[24]; // "http://" (7) + IPv4 max (15) + null
memcpy(captive_portal_uri, "http://", 7); // NOLINT(bugprone-not-null-terminated-result) - str_to null-terminates
-3
View File
@@ -371,7 +371,6 @@
#define USE_WEBSERVER
#define USE_WEBSERVER_AUTH
#define USE_WEBSERVER_AUTH_DIGEST
#define USE_WEBSERVER_CAPTIVE
#define USE_WEBSERVER_OTA
#define USE_WEBSERVER_PORT 80 // NOLINT
#define USE_WEBSERVER_GZIP
@@ -485,7 +484,6 @@
#define USE_WEBSERVER
#define USE_WEBSERVER_AUTH
#define USE_WEBSERVER_AUTH_DIGEST
#define USE_WEBSERVER_CAPTIVE
#define USE_WEBSERVER_PORT 80 // NOLINT
#endif
@@ -543,7 +541,6 @@
#define USE_WEBSERVER
#define USE_WEBSERVER_AUTH
#define USE_WEBSERVER_AUTH_DIGEST
#define USE_WEBSERVER_CAPTIVE
#define USE_WEBSERVER_PORT 80 // NOLINT
#define USE_ESPHOME_TASK_LOG_BUFFER
#define ESPHOME_TASK_LOG_BUFFER_SIZE 768
@@ -0,0 +1,4 @@
substitutions:
network_enable_ipv6: "true"
<<: !include common.yaml
@@ -1,11 +0,0 @@
# STA with AP fallback plus local: true opts the fallback into captive AP mode; exercises
# the runtime start (fallback branch in wifi loop) and end (on STA connect) paths.
wifi:
ssid: MySSID
password: password1
ap:
ssid: "ESPHome-Test"
password: "Test1234!"
web_server:
local: true
@@ -1,9 +0,0 @@
# AP mode: with a WiFi access point and the interface embedded in the firmware, web_server
# runs its own captive portal (DNS server, unknown URLs redirect to the page).
wifi:
ap:
ssid: "ESPHome-Test"
password: "Test1234!"
web_server:
local: true
@@ -1,2 +0,0 @@
packages:
web_server: !include common-ap-fallback.yaml
@@ -1,2 +0,0 @@
packages:
web_server: !include common-ap-mode.yaml
@@ -1,2 +0,0 @@
packages:
web_server: !include common-ap-mode.yaml
@@ -0,0 +1,17 @@
esphome:
name: socket-set-sockaddr
on_boot:
then:
- lambda: |-
// 0 for text that is not an address, the length otherwise, broadcast included
struct sockaddr_storage addr;
auto *sa = reinterpret_cast<struct sockaddr *>(&addr);
ESP_LOGI("test", "SET_SOCKADDR invalid=%u valid=%u broadcast=%u",
(unsigned) socket::set_sockaddr(sa, sizeof(addr), "not an address", 1234),
(unsigned) socket::set_sockaddr(sa, sizeof(addr), "192.0.2.1", 1234),
(unsigned) socket::set_sockaddr(sa, sizeof(addr), "255.255.255.255", 1234));
host:
api:
logger:
level: INFO
@@ -0,0 +1,40 @@
"""Integration test for the socket::set_sockaddr failure contract."""
import asyncio
import re
import pytest
from .types import APIClientConnectedFactory, RunCompiledFunction
@pytest.mark.asyncio
async def test_socket_set_sockaddr(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""set_sockaddr reports an invalid address with 0 and accepts broadcast."""
loop = asyncio.get_running_loop()
result: asyncio.Future[tuple[int, int, int]] = loop.create_future()
def on_log_line(line: str) -> None:
match = re.search(
r"SET_SOCKADDR invalid=(\d+) valid=(\d+) broadcast=(\d+)", line
)
if match and not result.done():
result.set_result(tuple(int(g) for g in match.groups()))
async with (
run_compiled(yaml_config, line_callback=on_log_line),
api_client_connected() as client,
):
assert (await client.device_info()).name == "socket-set-sockaddr"
try:
invalid, valid, broadcast = await asyncio.wait_for(result, timeout=10.0)
except TimeoutError:
pytest.fail("SET_SOCKADDR marker never appeared")
assert invalid == 0
assert valid > 0
assert broadcast == valid
@@ -1,150 +0,0 @@
"""Tests for the web_server AP mode helpers."""
import logging
import pytest
from esphome.components.web_server import (
_final_validate_ap_mode,
serve_captive,
serve_local,
)
from esphome.const import (
CONF_AP,
CONF_LOCAL,
CONF_NETWORKS,
CONF_PORT,
CONF_SSID,
CONF_VERSION,
CONF_WIFI,
)
import esphome.final_validate as fv
AP_ONLY = {CONF_AP: {}}
AP_FALLBACK = {CONF_AP: {}, CONF_NETWORKS: [{CONF_SSID: "x"}]}
STA_ONLY = {CONF_NETWORKS: [{CONF_SSID: "x"}]}
@pytest.mark.parametrize(
("web_server_config", "wifi_config", "expected"),
[
# AP only: embed the interface, the AP has no internet.
({CONF_VERSION: 2}, AP_ONLY, True),
({CONF_VERSION: 3}, AP_ONLY, True),
# Explicit setting always wins.
({CONF_VERSION: 2, CONF_LOCAL: False}, AP_ONLY, False),
({CONF_VERSION: 2, CONF_LOCAL: True}, STA_ONLY, True),
# AP fallback, no AP, no wifi, or version 1 (no local mode): hosted page.
({CONF_VERSION: 2}, AP_FALLBACK, False),
({CONF_VERSION: 2}, STA_ONLY, False),
({CONF_VERSION: 2}, None, False),
({CONF_VERSION: 1}, AP_ONLY, False),
],
)
def test_serve_local(
web_server_config: dict, wifi_config: dict | None, expected: bool
) -> None:
"""The interface is embedded for AP only WiFi unless local is set explicitly."""
assert serve_local(web_server_config, wifi_config) is expected
@pytest.mark.parametrize(
("web_server_config", "full_config", "expected"),
[
# AP only: local is implied, web_server is the captive portal.
({CONF_VERSION: 2}, {CONF_WIFI: AP_ONLY}, True),
# Captive portal probes only work on port 80.
({CONF_VERSION: 2, CONF_PORT: 8080}, {CONF_WIFI: AP_ONLY}, False),
# AP fallback needs an explicit local: true to be captive.
({CONF_VERSION: 2}, {CONF_WIFI: AP_FALLBACK}, False),
({CONF_VERSION: 2, CONF_LOCAL: True}, {CONF_WIFI: AP_FALLBACK}, True),
# captive_portal owns the role when configured.
({CONF_VERSION: 2}, {CONF_WIFI: AP_ONLY, "captive_portal": {}}, False),
# No AP, no wifi, hosted page, or version 1: never captive.
({CONF_VERSION: 2, CONF_LOCAL: True}, {CONF_WIFI: STA_ONLY}, False),
({CONF_VERSION: 2, CONF_LOCAL: True}, {}, False),
({CONF_VERSION: 2, CONF_LOCAL: False}, {CONF_WIFI: AP_ONLY}, False),
({CONF_VERSION: 1}, {CONF_WIFI: AP_ONLY}, False),
],
)
def test_serve_captive(
web_server_config: dict, full_config: dict, expected: bool
) -> None:
web_server_config.setdefault(CONF_PORT, 80)
assert serve_captive(web_server_config, full_config) is expected
@pytest.mark.parametrize(
("web_server_config", "expect_warning"),
[
# Explicit local: false on an AP only device: the hosted page will stay blank.
({CONF_VERSION: 2, CONF_PORT: 80, CONF_LOCAL: False}, True),
# Default: embedded and captive, nothing to warn about.
({CONF_VERSION: 2, CONF_PORT: 80}, False),
],
)
def test_final_validate_ap_mode_warns_for_hosted_page(
web_server_config: dict, expect_warning: bool, caplog: pytest.LogCaptureFixture
) -> None:
token = fv.full_config.set({"web_server": web_server_config, CONF_WIFI: AP_ONLY})
try:
with caplog.at_level(logging.WARNING):
_final_validate_ap_mode(web_server_config)
finally:
fv.full_config.reset(token)
assert ("stays blank" in caplog.text) is expect_warning
def test_final_validate_ap_mode_warns_for_non_default_port(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Captive portal detection needs port 80; other ports get a hint, not captive mode."""
config = {CONF_VERSION: 2, CONF_PORT: 8080}
token = fv.full_config.set({"web_server": config, CONF_WIFI: AP_ONLY})
try:
with caplog.at_level(logging.WARNING):
_final_validate_ap_mode(config)
finally:
fv.full_config.reset(token)
assert "cannot open automatically" in caplog.text
assert "http://192.168.4.1:8080/" in caplog.text
def test_final_validate_ap_mode_port_warning_uses_manual_ip(
caplog: pytest.LogCaptureFixture,
) -> None:
"""The manual URL in the port warning honors wifi.ap.manual_ip."""
from esphome.const import CONF_MANUAL_IP, CONF_STATIC_IP
config = {CONF_VERSION: 2, CONF_PORT: 8080}
wifi = {CONF_AP: {CONF_MANUAL_IP: {CONF_STATIC_IP: "10.0.0.1"}}}
token = fv.full_config.set({"web_server": config, CONF_WIFI: wifi})
try:
with caplog.at_level(logging.WARNING):
_final_validate_ap_mode(config)
finally:
fv.full_config.reset(token)
assert "http://10.0.0.1:8080/" in caplog.text
@pytest.mark.parametrize(
("wifi_config", "expected"),
[
# Explicit local: true on a fallback AP: announce the captive fallback role.
(AP_FALLBACK, "fallback access point"),
# Explicit local: true on AP only skips the implied-local info; still announce.
(AP_ONLY, "captive portal while the access point"),
],
)
def test_final_validate_ap_mode_informs_explicit_local_captive(
wifi_config: dict, expected: str, caplog: pytest.LogCaptureFixture
) -> None:
"""Explicit local: true logs that web_server becomes the captive portal."""
config = {CONF_VERSION: 2, CONF_PORT: 80, CONF_LOCAL: True}
token = fv.full_config.set({"web_server": config, CONF_WIFI: wifi_config})
try:
with caplog.at_level(logging.INFO):
_final_validate_ap_mode(config)
finally:
fv.full_config.reset(token)
assert expected in caplog.text