[web_server] Self contained AP mode: embed the interface and act as captive portal

This commit is contained in:
J. Nick Koston
2026-08-19 14:00:43 -05:00
parent 3b8b90b2cd
commit 4dad932cd8
13 changed files with 247 additions and 105 deletions
@@ -3,7 +3,6 @@ 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,
@@ -15,7 +14,6 @@ from esphome.const import (
PLATFORM_LN882X,
PLATFORM_RP2,
PLATFORM_RTL87XX,
PlatformFramework,
)
from esphome.core import CORE, coroutine_with_priority
from esphome.coroutine import CoroPriority
@@ -108,14 +106,3 @@ async def to_code(config):
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,
},
}
)
@@ -3,7 +3,7 @@
#ifdef USE_CAPTIVE_PORTAL
#include <memory>
#if defined(USE_ESP32)
#include "dns_server_esp32_idf.h"
#include "esphome/components/web_server_base/dns_server_esp32_idf.h"
#elif defined(USE_ARDUINO)
#include <DNSServer.h>
#endif
@@ -14,6 +14,10 @@
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);
+51 -33
View File
@@ -23,13 +23,11 @@ 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,
@@ -49,7 +47,14 @@ from esphome.types import ConfigType
_LOGGER = logging.getLogger(__name__)
AUTO_LOAD = ["json", "web_server_base"]
def AUTO_LOAD() -> 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
auto_load.append("socket")
return auto_load
AUTH_TYPE_BASIC = "basic"
AUTH_TYPE_DIGEST = "digest"
@@ -334,56 +339,61 @@ 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_config is not None
and CONF_AP in wifi_config
and not wifi_config.get(CONF_NETWORKS)
)
return wifi_has_ap(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
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."""
if (local := config.get(CONF_LOCAL)) is not None:
return local
return config[CONF_VERSION] != 1 and wifi_is_ap_only(wifi_config)
def _final_validate_ap_only(config: ConfigType) -> None:
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."""
return (
serve_local(config, wifi_config)
and wifi_has_ap(wifi_config)
and not has_captive_portal
)
def _final_validate_ap_mode(config: ConfigType) -> None:
full_config = fv.full_config.get()
wifi_config = full_config.get(CONF_WIFI)
if not wifi_is_ap_only(wifi_config):
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
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])
captive = (
"" if "captive_portal" in full_config else " (the AP is not a captive portal)"
)
if serve_local(config, wifi_config):
how = "The web interface is embedded in the firmware (local: true)"
else:
how = (
"With local: false the web interface is loaded from the internet, which "
"browsers on the AP usually cannot reach, so the page stays blank"
if 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 "
"reach; the page stays blank. Remove 'local: false' to embed it in the firmware."
)
_LOGGER.warning(
"WiFi is AP only, so web_server is reachable only through the access point: "
"open http://%s/ manually after joining it%s. %s.",
ap_ip,
captive,
how,
)
def _final_validate(config: ConfigType) -> None:
_final_validate_sorting(config)
_final_validate_ap_only(config)
_final_validate_ap_mode(config)
FINAL_VALIDATE_SCHEMA = _final_validate
@@ -489,8 +499,16 @@ 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]))
if serve_local(config, CORE.config.get(CONF_WIFI)):
wifi_config = CORE.config.get(CONF_WIFI)
if serve_local(config, wifi_config):
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
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)
if config[CONF_COMPRESSION] == "gzip":
cg.add_define("USE_WEBSERVER_GZIP")
+67 -4
View File
@@ -44,6 +44,10 @@
#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"
@@ -340,7 +344,9 @@ void DeferredUpdateEventSourceList::on_client_disconnect_(DeferredUpdateEventSou
}
#endif
WebServer::WebServer(web_server_base::WebServerBase *base) : base_(base) {}
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; }
#ifdef USE_WEBSERVER_CSS_INCLUDE
void WebServer::set_css_include(const char *css_include) { this->css_include_ = css_include; }
@@ -401,16 +407,54 @@ void WebServer::setup() {
});
}
void WebServer::loop() {
// No SSE clients connected; stop looping until a new client connects via
// enable_loop_soon_any_context(). This is safe because:
bool busy = 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;
}
#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:
// - 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 (!this->events_.loop())
if (!busy)
this->disable_loop();
}
#ifdef USE_WEBSERVER_CAPTIVE
void WebServer::start_captive() {
if (this->captive_)
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->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_)
return;
this->captive_ = false;
this->dns_server_->stop();
this->dns_server_ = nullptr;
}
#endif
#ifdef USE_LOGGER
void WebServer::on_log(uint8_t level, const char *tag, const char *message, size_t message_len) {
(void) level;
@@ -2335,6 +2379,13 @@ 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;
@@ -2640,6 +2691,18 @@ 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"));
@@ -4,6 +4,13 @@
#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
#endif
#ifdef USE_WEBSERVER
#include "esphome/core/component.h"
#include "esphome/core/controller.h"
@@ -14,6 +21,7 @@
#include <functional>
#include <list>
#include <memory>
#include <map>
#include <string>
#include <utility>
@@ -36,6 +44,10 @@ 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 *;
@@ -276,6 +288,16 @@ 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 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.
*/
void start_captive();
void end_captive();
bool is_captive() const { return this->captive_; }
#endif
/// Return the webserver configuration as JSON.
json::SerializationBuffer<> get_config_json();
@@ -597,6 +619,10 @@ class WebServer final : public Controller, public Component, public AsyncWebHand
#elif USE_ARDUINO
DeferredUpdateEventSourceList events_;
#endif
#ifdef USE_WEBSERVER_CAPTIVE
std::unique_ptr<DNSServer> dns_server_;
bool captive_{false};
#endif
#if USE_WEBSERVER_VERSION == 1
const char *css_url_{nullptr};
@@ -696,5 +722,7 @@ class WebServer final : public Controller, public Component, public AsyncWebHand
#endif
};
extern WebServer *global_web_server; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
} // namespace esphome::web_server
#endif
+14 -1
View File
@@ -1,8 +1,9 @@
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
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
@@ -80,3 +81,15 @@ async def to_code(config):
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,5 +1,5 @@
#include "dns_server_esp32_idf.h"
#ifdef USE_ESP32
#if defined(USE_ESP32) && (defined(USE_CAPTIVE_PORTAL) || defined(USE_WEBSERVER_CAPTIVE))
#include "esphome/core/log.h"
#include "esphome/core/hal.h"
@@ -7,9 +7,9 @@
#include <lwip/sockets.h>
#include <lwip/inet.h>
namespace esphome::captive_portal {
namespace esphome::web_server_base {
static const char *const TAG = "captive_portal.dns";
static const char *const TAG = "web_server_base.dns";
// DNS constants
static constexpr uint16_t DNS_PORT = 53;
@@ -202,6 +202,6 @@ void DNSServer::process_next_request() {
}
}
} // namespace esphome::captive_portal
} // namespace esphome::web_server_base
#endif // USE_ESP32
#endif // USE_ESP32 && (USE_CAPTIVE_PORTAL || USE_WEBSERVER_CAPTIVE)
@@ -1,11 +1,15 @@
#pragma once
#ifdef USE_ESP32
#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))
#include "esphome/core/helpers.h"
#include "esphome/components/network/ip_address.h"
#include "esphome/components/socket/socket.h"
namespace esphome::captive_portal {
namespace esphome::web_server_base {
class DNSServer {
public:
@@ -27,6 +31,6 @@ class DNSServer {
uint8_t buffer_[DNS_BUFFER_SIZE];
};
} // namespace esphome::captive_portal
} // namespace esphome::web_server_base
#endif // USE_ESP32
#endif // USE_ESP32 && (USE_CAPTIVE_PORTAL || USE_WEBSERVER_CAPTIVE)
@@ -36,6 +36,9 @@
#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"
@@ -731,6 +734,9 @@ void WiFiComponent::start() {
captive_portal::global_captive_portal->start();
}
#endif
#ifdef USE_WEBSERVER_CAPTIVE
web_server::global_web_server->start_captive();
#endif
#endif // USE_WIFI_AP
}
#ifdef USE_IMPROV
@@ -867,6 +873,9 @@ void WiFiComponent::loop() {
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();
#endif
}
}
@@ -1622,6 +1631,9 @@ void WiFiComponent::check_connecting_finished(uint32_t now) {
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
ESP_LOGD(TAG, "Disabling AP");
this->wifi_mode_({}, false);
+3
View File
@@ -353,6 +353,7 @@
#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
@@ -466,6 +467,7 @@
#define USE_WEBSERVER
#define USE_WEBSERVER_AUTH
#define USE_WEBSERVER_AUTH_DIGEST
#define USE_WEBSERVER_CAPTIVE
#define USE_WEBSERVER_PORT 80 // NOLINT
#endif
@@ -523,6 +525,7 @@
#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,9 @@
# 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
@@ -0,0 +1,9 @@
# 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
+36 -44
View File
@@ -1,21 +1,20 @@
"""Tests for web_server component helpers."""
"""Tests for the web_server AP mode helpers."""
import logging
import pytest
from esphome.components.web_server import (
_final_validate_ap_only,
_final_validate_ap_mode,
serve_captive,
serve_local,
wifi_is_ap_only,
)
from esphome.const import (
CONF_AP,
CONF_LOCAL,
CONF_MANUAL_IP,
CONF_NETWORKS,
CONF_SSID,
CONF_STATIC_IP,
CONF_VERSION,
CONF_WIFI,
)
@@ -58,52 +57,45 @@ def test_serve_local(
@pytest.mark.parametrize(
("web_server_config", "full_config", "expected"),
("web_server_config", "wifi_config", "has_captive_portal", "expected"),
[
({CONF_VERSION: 2}, {CONF_WIFI: AP_ONLY}, "http://192.168.4.1/"),
(
{CONF_VERSION: 2},
{CONF_WIFI: {CONF_AP: {CONF_MANUAL_IP: {CONF_STATIC_IP: "10.0.0.1"}}}},
"http://10.0.0.1/",
),
({CONF_VERSION: 2}, {CONF_WIFI: AP_ONLY}, "not a captive portal"),
({CONF_VERSION: 2}, {CONF_WIFI: AP_ONLY}, "embedded in the firmware"),
({CONF_VERSION: 2, CONF_LOCAL: False}, {CONF_WIFI: AP_ONLY}, "stays blank"),
({CONF_VERSION: 2}, {CONF_WIFI: AP_FALLBACK}, None),
({CONF_VERSION: 2}, {CONF_WIFI: STA_ONLY}, None),
({CONF_VERSION: 2}, {}, None),
# AP only: local is implied, web_server is the captive portal.
({CONF_VERSION: 2}, AP_ONLY, False, 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),
# 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),
],
)
def test_final_validate_ap_only_warning(
def test_serve_captive(
web_server_config: dict,
full_config: dict,
expected: str | None,
caplog: pytest.LogCaptureFixture,
wifi_config: dict | None,
has_captive_portal: bool,
expected: bool,
) -> None:
"""AP only WiFi with web_server warns and names the AP address; others stay quiet."""
token = fv.full_config.set({"web_server": web_server_config, **full_config})
try:
with caplog.at_level(logging.WARNING):
_final_validate_ap_only(web_server_config)
finally:
fv.full_config.reset(token)
if expected is None:
assert "AP only" not in caplog.text
else:
assert expected in caplog.text
assert serve_captive(web_server_config, wifi_config, has_captive_portal) is expected
def test_final_validate_ap_only_with_captive_portal(
def test_final_validate_ap_mode_warns_for_hosted_page(
caplog: pytest.LogCaptureFixture,
) -> None:
"""With captive_portal the AP is captive, so that clause is left out."""
token = fv.full_config.set(
{"web_server": {CONF_VERSION: 2}, CONF_WIFI: AP_ONLY, "captive_portal": {}}
)
try:
with caplog.at_level(logging.WARNING):
_final_validate_ap_only({CONF_VERSION: 2})
finally:
fv.full_config.reset(token)
assert "AP only" in caplog.text
assert "captive portal" not in caplog.text
"""AP only with an explicit local: false gets a warning; AP only default does not."""
for web_server_config, expect_warning in (
({CONF_VERSION: 2, CONF_LOCAL: False}, True),
({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