mirror of
https://github.com/esphome/esphome.git
synced 2026-08-31 01:56:01 +00:00
[web_server] Share the captive DNS server, use the not-found fallback for probes, fold portal hooks in wifi
This commit is contained in:
@@ -74,17 +74,8 @@ def _final_validate(config: ConfigType) -> None:
|
||||
"Add 'ap:' to your WiFi configuration to enable the 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)
|
||||
web_server_base.consume_captive_dns_sockets(config, "captive_portal")
|
||||
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = _final_validate
|
||||
@@ -104,5 +95,4 @@ async def to_code(config):
|
||||
if config[CONF_COMPRESSION] == "gzip":
|
||||
cg.add_define("USE_CAPTIVE_PORTAL_GZIP")
|
||||
|
||||
if CORE.using_arduino and (CORE.is_esp8266 or CORE.is_libretiny or CORE.is_rp2):
|
||||
cg.add_library("DNSServer", None)
|
||||
web_server_base.add_captive_dns_library()
|
||||
|
||||
@@ -82,17 +82,7 @@ void CaptivePortal::start() {
|
||||
this->base_->add_handler_without_auth(this);
|
||||
}
|
||||
|
||||
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->dns_.start(wifi::global_wifi_component->wifi_soft_ap_ip());
|
||||
|
||||
this->initialized_ = true;
|
||||
this->active_ = true;
|
||||
|
||||
@@ -1,39 +1,20 @@
|
||||
#pragma once
|
||||
#include "esphome/core/defines.h"
|
||||
#ifdef USE_CAPTIVE_PORTAL
|
||||
#include <memory>
|
||||
#if defined(USE_ESP32)
|
||||
#include "esphome/components/web_server_base/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 {
|
||||
|
||||
#if defined(USE_ESP32)
|
||||
using web_server_base::DNSServer;
|
||||
#endif
|
||||
|
||||
class CaptivePortal final : public AsyncWebHandler, public Component {
|
||||
public:
|
||||
CaptivePortal(web_server_base::WebServerBase *base);
|
||||
void setup() override;
|
||||
void dump_config() override;
|
||||
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
|
||||
}
|
||||
void loop() override { this->dns_.loop(); }
|
||||
float get_setup_priority() const override;
|
||||
void start();
|
||||
bool is_active() const { return this->active_; }
|
||||
@@ -41,10 +22,7 @@ class CaptivePortal final : public AsyncWebHandler, public Component {
|
||||
this->active_ = false;
|
||||
this->disable_loop(); // Stop processing DNS requests
|
||||
this->base_->deinit();
|
||||
if (this->dns_server_ != nullptr) {
|
||||
this->dns_server_->stop();
|
||||
this->dns_server_ = nullptr;
|
||||
}
|
||||
this->dns_.stop();
|
||||
}
|
||||
|
||||
bool canHandle(AsyncWebServerRequest *request) const override {
|
||||
@@ -64,9 +42,7 @@ class CaptivePortal final : public AsyncWebHandler, public Component {
|
||||
web_server_base::WebServerBase *base_;
|
||||
bool initialized_{false};
|
||||
bool active_{false};
|
||||
#if defined(USE_ARDUINO) || defined(USE_ESP32)
|
||||
std::unique_ptr<DNSServer> dns_server_{nullptr};
|
||||
#endif
|
||||
web_server_base::CaptiveDNS dns_;
|
||||
};
|
||||
|
||||
extern CaptivePortal *global_captive_portal; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
|
||||
@@ -48,10 +48,12 @@ from esphome.types import ConfigType
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def AUTO_LOAD() -> list[str]:
|
||||
def AUTO_LOAD(config: ConfigType) -> list[str]:
|
||||
auto_load = ["json", "web_server_base"]
|
||||
if CORE.is_esp32:
|
||||
# The AP mode DNS server (web_server_base/dns_server_esp32_idf) uses socket
|
||||
# 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.
|
||||
wifi = CORE.raw_config.get(CONF_WIFI) if CORE.raw_config is not None else None
|
||||
if CORE.is_esp32 and (not isinstance(wifi, dict) or CONF_AP in wifi):
|
||||
auto_load.append("socket")
|
||||
return auto_load
|
||||
|
||||
@@ -339,51 +341,42 @@ async def add_entity_config(entity, config):
|
||||
)
|
||||
|
||||
|
||||
def wifi_has_ap(wifi_config: ConfigType | None) -> bool:
|
||||
return wifi_config is not None and CONF_AP in wifi_config
|
||||
|
||||
|
||||
def wifi_is_ap_only(wifi_config: ConfigType | None) -> bool:
|
||||
"""Return True when WiFi has an access point but no network to join, so the device
|
||||
is only ever reached through its own AP."""
|
||||
return wifi_has_ap(wifi_config) and not wifi_config.get(CONF_NETWORKS)
|
||||
"""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:
|
||||
"""Return True when the web interface is embedded in the firmware instead of
|
||||
loaded from oi.esphome.io. An explicit ``local:`` wins. Otherwise it is embedded for AP
|
||||
only WiFi, since browsers on the AP usually have no internet and the hosted page would
|
||||
stay blank. Version 1 has no local mode."""
|
||||
"""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, wifi_config: ConfigType | None, has_captive_portal: bool
|
||||
) -> bool:
|
||||
"""Return True when web_server runs its own captive portal (DNS server plus the
|
||||
interface for every URL) while the WiFi access point is up: the interface must be
|
||||
embedded, an AP must exist, and captive_portal (which owns that role when present)
|
||||
must not be configured."""
|
||||
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."""
|
||||
wifi_config = full_config.get(CONF_WIFI)
|
||||
return (
|
||||
serve_local(config, wifi_config)
|
||||
and wifi_has_ap(wifi_config)
|
||||
and not has_captive_portal
|
||||
"captive_portal" not in full_config
|
||||
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)
|
||||
if serve_captive(config, wifi_config, "captive_portal" in full_config):
|
||||
# Sockets for the DNS server and the OS captive portal probes, like captive_portal.
|
||||
from esphome.components import socket
|
||||
|
||||
socket.consume_sockets(3, "web_server")(config)
|
||||
socket.consume_sockets(1, "web_server", socket.SocketType.UDP)(config)
|
||||
return
|
||||
if wifi_is_ap_only(wifi_config) and not serve_local(config, wifi_config):
|
||||
if serve_captive(config, full_config):
|
||||
web_server_base.consume_captive_dns_sockets(config, "web_server")
|
||||
elif wifi_is_ap_only(wifi_config) and not serve_local(config, wifi_config):
|
||||
_LOGGER.warning(
|
||||
"WiFi is AP only and web_server has local: false, so the web interface is "
|
||||
"loaded from the internet, which browsers on the access point usually cannot "
|
||||
@@ -392,6 +385,7 @@ def _final_validate_ap_mode(config: ConfigType) -> None:
|
||||
|
||||
|
||||
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)
|
||||
|
||||
@@ -499,16 +493,12 @@ async def to_code(config):
|
||||
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]))
|
||||
wifi_config = CORE.config.get(CONF_WIFI)
|
||||
if serve_local(config, wifi_config):
|
||||
if serve_local(config, CORE.config.get(CONF_WIFI)):
|
||||
cg.add_define("USE_WEBSERVER_LOCAL")
|
||||
if serve_captive(
|
||||
config, wifi_config, CORE.has_at_least_one_component("captive_portal")
|
||||
):
|
||||
# AP mode: DNS server plus catch-all page so phones open the interface by themselves
|
||||
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")
|
||||
if CORE.using_arduino and (CORE.is_esp8266 or CORE.is_libretiny or CORE.is_rp2):
|
||||
cg.add_library("DNSServer", None)
|
||||
web_server_base.add_captive_dns_library()
|
||||
if config[CONF_COMPRESSION] == "gzip":
|
||||
cg.add_define("USE_WEBSERVER_GZIP")
|
||||
|
||||
|
||||
@@ -344,9 +344,13 @@ void DeferredUpdateEventSourceList::on_client_disconnect_(DeferredUpdateEventSou
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef USE_WEBSERVER_CAPTIVE
|
||||
WebServer *global_web_server = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
|
||||
WebServer::WebServer(web_server_base::WebServerBase *base) : base_(base) { global_web_server = this; }
|
||||
#else
|
||||
WebServer::WebServer(web_server_base::WebServerBase *base) : base_(base) {}
|
||||
#endif
|
||||
|
||||
#ifdef USE_WEBSERVER_CSS_INCLUDE
|
||||
void WebServer::set_css_include(const char *css_include) { this->css_include_ = css_include; }
|
||||
@@ -392,6 +396,12 @@ 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(
|
||||
[](AsyncWebServerRequest *request) { global_web_server->handle_not_found_(request); });
|
||||
#endif
|
||||
|
||||
// OTA is now handled by the web_server OTA platform
|
||||
|
||||
@@ -407,51 +417,48 @@ void WebServer::setup() {
|
||||
});
|
||||
}
|
||||
void WebServer::loop() {
|
||||
bool busy = this->events_.loop();
|
||||
bool has_clients = this->events_.loop();
|
||||
#ifdef USE_WEBSERVER_CAPTIVE
|
||||
if (this->captive_) {
|
||||
#if defined(USE_ESP32)
|
||||
this->dns_server_->process_next_request();
|
||||
#elif defined(USE_ARDUINO)
|
||||
this->dns_server_->processNextRequest();
|
||||
#endif
|
||||
busy = true;
|
||||
if (this->dns_.is_running()) {
|
||||
this->dns_.loop();
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
// No SSE clients connected (and no captive DNS to serve); stop looping until a new
|
||||
// client connects via enable_loop_soon_any_context(). This is safe because:
|
||||
// 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 (!busy)
|
||||
if (!has_clients)
|
||||
this->disable_loop();
|
||||
}
|
||||
|
||||
#ifdef USE_WEBSERVER_CAPTIVE
|
||||
void WebServer::start_captive() {
|
||||
if (this->captive_)
|
||||
if (this->dns_.is_running())
|
||||
return;
|
||||
network::IPAddress ip = wifi::global_wifi_component->wifi_soft_ap_ip();
|
||||
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
|
||||
this->captive_ = true;
|
||||
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() {
|
||||
if (!this->captive_)
|
||||
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) {
|
||||
char location[7 + network::IP_ADDRESS_BUFFER_SIZE];
|
||||
size_t pos = buf_append_str(location, sizeof(location), 0, "http://");
|
||||
wifi::global_wifi_component->wifi_soft_ap_ip().str_to(location + pos);
|
||||
request->redirect(location);
|
||||
return;
|
||||
this->captive_ = false;
|
||||
this->dns_server_->stop();
|
||||
this->dns_server_ = nullptr;
|
||||
}
|
||||
request->send(404);
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -2379,13 +2386,6 @@ bool WebServer::canHandle(AsyncWebServerRequest *request) const {
|
||||
#endif
|
||||
const auto method = request->method();
|
||||
|
||||
#ifdef USE_WEBSERVER_CAPTIVE
|
||||
// AP mode: answer every GET; unknown URLs redirect to the index page, so the OS captive
|
||||
// portal check (generate_204, hotspot-detect.html, ...) opens the interface.
|
||||
if (this->captive_ && method == HTTP_GET)
|
||||
return true;
|
||||
#endif
|
||||
|
||||
// Static URL checks - use ESPHOME_F to keep strings in flash on ESP8266
|
||||
if (url == ESPHOME_F("/"))
|
||||
return true;
|
||||
@@ -2691,18 +2691,6 @@ void WebServer::handleRequest(AsyncWebServerRequest *request) {
|
||||
}
|
||||
#endif
|
||||
else {
|
||||
#ifdef USE_WEBSERVER_CAPTIVE
|
||||
if (this->captive_ && request->method() == HTTP_GET) {
|
||||
// OS captive portal probe (or any other unknown page): 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.
|
||||
char location[7 + network::IP_ADDRESS_BUFFER_SIZE];
|
||||
memcpy(location, "http://", 7); // NOLINT(bugprone-not-null-terminated-result) - str_to null-terminates
|
||||
wifi::global_wifi_component->wifi_soft_ap_ip().str_to(location + 7);
|
||||
request->redirect(location);
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
// No matching handler found - send 404
|
||||
ESP_LOGV(TAG, "Request for unknown URL: %s", url.c_str());
|
||||
request->send(404, ESPHOME_F("text/plain"), ESPHOME_F("Not Found"));
|
||||
|
||||
@@ -5,11 +5,7 @@
|
||||
#include "esphome/components/json/json_util.h"
|
||||
#include "esphome/components/web_server_base/web_server_base.h"
|
||||
#ifdef USE_WEBSERVER_CAPTIVE
|
||||
#if defined(USE_ESP32)
|
||||
#include "esphome/components/web_server_base/dns_server_esp32_idf.h"
|
||||
#elif defined(USE_ARDUINO)
|
||||
#include <DNSServer.h>
|
||||
#endif
|
||||
#include "esphome/components/web_server_base/captive_dns.h"
|
||||
#endif
|
||||
#ifdef USE_WEBSERVER
|
||||
#include "esphome/core/component.h"
|
||||
@@ -21,7 +17,6 @@
|
||||
|
||||
#include <functional>
|
||||
#include <list>
|
||||
#include <memory>
|
||||
#include <map>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
@@ -44,10 +39,6 @@ extern const size_t ESPHOME_WEBSERVER_JS_INCLUDE_SIZE;
|
||||
|
||||
namespace esphome::web_server {
|
||||
|
||||
#if defined(USE_WEBSERVER_CAPTIVE) && defined(USE_ESP32)
|
||||
using web_server_base::DNSServer;
|
||||
#endif
|
||||
|
||||
// Type for parameter names that can be stored in flash on ESP8266
|
||||
#ifdef USE_ESP8266
|
||||
using ParamNameType = const __FlashStringHelper *;
|
||||
@@ -289,13 +280,14 @@ class WebServer final : public Controller, public Component, public AsyncWebHand
|
||||
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 serve the
|
||||
* interface for any unknown URL, 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.
|
||||
/** 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(), so it touches nothing but the DNS server.
|
||||
*/
|
||||
void start_captive();
|
||||
void end_captive();
|
||||
bool is_captive() const { return this->captive_; }
|
||||
bool is_captive() const { return this->dns_.is_running(); }
|
||||
#endif
|
||||
|
||||
/// Return the webserver configuration as JSON.
|
||||
@@ -620,8 +612,8 @@ class WebServer final : public Controller, public Component, public AsyncWebHand
|
||||
DeferredUpdateEventSourceList events_;
|
||||
#endif
|
||||
#ifdef USE_WEBSERVER_CAPTIVE
|
||||
std::unique_ptr<DNSServer> dns_server_;
|
||||
bool captive_{false};
|
||||
void handle_not_found_(AsyncWebServerRequest *request);
|
||||
web_server_base::CaptiveDNS dns_;
|
||||
#endif
|
||||
|
||||
#if USE_WEBSERVER_VERSION == 1
|
||||
@@ -722,7 +714,9 @@ 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
|
||||
|
||||
@@ -7,6 +7,7 @@ from esphome.const import CONF_ID, PlatformFramework
|
||||
from esphome.core import CORE, coroutine_with_priority
|
||||
from esphome.coroutine import CoroPriority
|
||||
from esphome.helpers import copy_file_if_changed
|
||||
from esphome.types import ConfigType
|
||||
|
||||
CODEOWNERS = ["@esphome/core"]
|
||||
DEPENDENCIES = ["network"]
|
||||
@@ -26,6 +27,22 @@ 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):
|
||||
"""Register the shared listening socket for the HTTP server.
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
#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 std::string &url) {
|
||||
void AsyncWebServerRequest::redirect(const char *url) {
|
||||
httpd_resp_set_status(*this, "302 Found");
|
||||
httpd_resp_set_hdr(*this, "Location", url.c_str());
|
||||
httpd_resp_set_hdr(*this, "Location", url);
|
||||
httpd_resp_set_hdr(*this, "Connection", "close");
|
||||
httpd_resp_send(*this, nullptr, 0);
|
||||
}
|
||||
|
||||
@@ -132,7 +132,8 @@ class AsyncWebServerRequest {
|
||||
void requestAuthentication() const;
|
||||
#endif
|
||||
|
||||
void redirect(const std::string &url);
|
||||
void redirect(const char *url);
|
||||
void redirect(const std::string &url) { this->redirect(url.c_str()); }
|
||||
|
||||
inline void ESPHOME_ALWAYS_INLINE send(AsyncWebServerResponse *response) {
|
||||
httpd_resp_send(*this, response->get_content_data(), response->get_content_size());
|
||||
|
||||
@@ -731,12 +731,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
|
||||
#ifdef USE_WEBSERVER_CAPTIVE
|
||||
web_server::global_web_server->start_captive();
|
||||
#endif
|
||||
this->start_ap_portal_();
|
||||
#endif // USE_WIFI_AP
|
||||
}
|
||||
#ifdef USE_IMPROV
|
||||
@@ -797,8 +794,8 @@ void WiFiComponent::loop() {
|
||||
this->check_connecting_finished(now);
|
||||
break;
|
||||
}
|
||||
// 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_();
|
||||
// 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_();
|
||||
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
|
||||
@@ -867,16 +864,11 @@ void WiFiComponent::loop() {
|
||||
ESP_LOGI(TAG, "Starting fallback AP");
|
||||
this->setup_ap_config_();
|
||||
#ifdef USE_CAPTIVE_PORTAL
|
||||
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
|
||||
#ifdef USE_WEBSERVER_CAPTIVE
|
||||
web_server::global_web_server->start_captive();
|
||||
// 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;
|
||||
#endif
|
||||
this->start_ap_portal_();
|
||||
}
|
||||
}
|
||||
#endif // USE_WIFI_AP
|
||||
@@ -1627,14 +1619,7 @@ void WiFiComponent::check_connecting_finished(uint32_t now) {
|
||||
this->retry_phase_ = WiFiRetryPhase::INITIAL_CONNECT;
|
||||
this->num_retried_ = 0;
|
||||
if (this->has_ap()) {
|
||||
#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
|
||||
this->end_ap_portal_();
|
||||
ESP_LOGD(TAG, "Disabling AP");
|
||||
this->wifi_mode_({}, false);
|
||||
}
|
||||
@@ -1960,10 +1945,10 @@ bool WiFiComponent::transition_to_phase_(WiFiRetryPhase new_phase) {
|
||||
break;
|
||||
|
||||
case WiFiRetryPhase::RESTARTING_ADAPTER:
|
||||
// Skip actual adapter restart if captive portal/improv is active
|
||||
// Skip actual adapter restart if a portal/improv is active
|
||||
// This allows state machine to reset num_retried_ and trigger fresh scan
|
||||
// without disrupting the captive portal/improv connection
|
||||
if (!this->is_captive_portal_active_() && !this->is_esp32_improv_active_()) {
|
||||
// without disrupting the portal/improv connection
|
||||
if (!this->is_ap_portal_active_() && !this->is_esp32_improv_active_()) {
|
||||
this->restart_adapter();
|
||||
} else {
|
||||
// Even when skipping full restart, disconnect to clear driver state
|
||||
@@ -2225,6 +2210,36 @@ 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
|
||||
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();
|
||||
|
||||
@@ -789,6 +789,12 @@ 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
|
||||
|
||||
@@ -1128,10 +1128,15 @@ bool WiFiComponent::wifi_ap_ip_config_(const optional<ManualIP> &manual_ip) {
|
||||
return false;
|
||||
}
|
||||
|
||||
#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) {
|
||||
#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_CAPTIVE_PORTAL
|
||||
const bool has_portal = captive_portal::global_captive_portal != nullptr;
|
||||
#else
|
||||
const bool has_portal = true;
|
||||
#endif
|
||||
if (has_portal) {
|
||||
// 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
|
||||
|
||||
@@ -8,7 +8,6 @@ from esphome.components.web_server import (
|
||||
_final_validate_ap_mode,
|
||||
serve_captive,
|
||||
serve_local,
|
||||
wifi_is_ap_only,
|
||||
)
|
||||
from esphome.const import (
|
||||
CONF_AP,
|
||||
@@ -25,14 +24,6 @@ AP_FALLBACK = {CONF_AP: {}, CONF_NETWORKS: [{CONF_SSID: "x"}]}
|
||||
STA_ONLY = {CONF_NETWORKS: [{CONF_SSID: "x"}]}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("wifi_config", "expected"),
|
||||
[(AP_ONLY, True), (AP_FALLBACK, False), (STA_ONLY, False), (None, False)],
|
||||
)
|
||||
def test_wifi_is_ap_only(wifi_config: dict | None, expected: bool) -> None:
|
||||
assert wifi_is_ap_only(wifi_config) is expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("web_server_config", "wifi_config", "expected"),
|
||||
[
|
||||
@@ -57,45 +48,44 @@ def test_serve_local(
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("web_server_config", "wifi_config", "has_captive_portal", "expected"),
|
||||
("web_server_config", "full_config", "expected"),
|
||||
[
|
||||
# AP only: local is implied, web_server is the captive portal.
|
||||
({CONF_VERSION: 2}, AP_ONLY, False, True),
|
||||
({CONF_VERSION: 2}, {CONF_WIFI: AP_ONLY}, True),
|
||||
# AP fallback needs an explicit local: true to be captive.
|
||||
({CONF_VERSION: 2}, AP_FALLBACK, False, False),
|
||||
({CONF_VERSION: 2, CONF_LOCAL: True}, AP_FALLBACK, False, True),
|
||||
({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}, AP_ONLY, True, False),
|
||||
# No AP, hosted page, or version 1: never captive.
|
||||
({CONF_VERSION: 2, CONF_LOCAL: True}, STA_ONLY, False, False),
|
||||
({CONF_VERSION: 2, CONF_LOCAL: False}, AP_ONLY, False, False),
|
||||
({CONF_VERSION: 1}, AP_ONLY, False, False),
|
||||
({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,
|
||||
wifi_config: dict | None,
|
||||
has_captive_portal: bool,
|
||||
expected: bool,
|
||||
web_server_config: dict, full_config: dict, expected: bool
|
||||
) -> None:
|
||||
assert serve_captive(web_server_config, wifi_config, has_captive_portal) is expected
|
||||
assert serve_captive(web_server_config, full_config) is expected
|
||||
|
||||
|
||||
def test_final_validate_ap_mode_warns_for_hosted_page(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""AP only with an explicit local: false gets a warning; AP only default does not."""
|
||||
for web_server_config, expect_warning in (
|
||||
@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_LOCAL: False}, True),
|
||||
# Default: embedded and captive, nothing to warn about.
|
||||
({CONF_VERSION: 2}, False),
|
||||
):
|
||||
caplog.clear()
|
||||
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_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
|
||||
|
||||
Reference in New Issue
Block a user