mirror of
https://github.com/esphome/esphome.git
synced 2026-10-14 02:02:51 +00:00
[web_server] Self contained AP mode: embedded interface served as a captive portal (#18517)
This commit is contained in:
@@ -4,7 +4,6 @@ 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.components.wifi import DOMAIN as WIFI_DOMAIN
|
||||
from esphome.config_helpers import filter_source_files_from_platform
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_AP,
|
||||
@@ -16,7 +15,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
|
||||
@@ -78,17 +76,7 @@ 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, DOMAIN)
|
||||
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = _final_validate
|
||||
@@ -108,16 +96,4 @@ async def to_code(config: ConfigType) -> None:
|
||||
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)
|
||||
|
||||
|
||||
# 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,
|
||||
},
|
||||
}
|
||||
)
|
||||
web_server_base.add_captive_dns_library()
|
||||
|
||||
@@ -6,9 +6,6 @@
|
||||
#include "esphome/core/string_ref.h"
|
||||
#include "esphome/components/wifi/scan_list.h"
|
||||
#include "esphome/components/wifi/wifi_component.h"
|
||||
#ifdef USE_PROVISIONING
|
||||
#include "esphome/components/provisioning/provisioning.h"
|
||||
#endif
|
||||
#include "captive_index.h"
|
||||
|
||||
namespace esphome::captive_portal {
|
||||
@@ -81,20 +78,6 @@ void CaptivePortal::handle_wifisave(AsyncWebServerRequest *request) {
|
||||
void CaptivePortal::setup() {
|
||||
// Disable loop by default - will be enabled when captive portal starts
|
||||
this->disable_loop();
|
||||
#ifdef USE_PROVISIONING
|
||||
// The captive portal is a provisioning surface: once the provisioning window
|
||||
// has closed, stop serving it. WiFi's own closed-callback shuts down the
|
||||
// access point the portal runs on, and the gated fallback in WiFiComponent's
|
||||
// loop() ensures neither is started again afterwards.
|
||||
if (provisioning::global_provisioning_manager != nullptr) {
|
||||
provisioning::global_provisioning_manager->add_on_closed_callback([this]() {
|
||||
if (this->active_) {
|
||||
ESP_LOGD(TAG, "Provisioning window closed; stopping captive portal");
|
||||
this->end();
|
||||
}
|
||||
});
|
||||
}
|
||||
#endif
|
||||
}
|
||||
void CaptivePortal::start() {
|
||||
this->base_->init();
|
||||
@@ -102,17 +85,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,16 +1,11 @@
|
||||
#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 {
|
||||
|
||||
@@ -19,17 +14,7 @@ class CaptivePortal final : public AsyncWebHandler, public Component {
|
||||
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_; }
|
||||
@@ -37,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 {
|
||||
@@ -60,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)
|
||||
|
||||
@@ -13,6 +13,7 @@ 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,15 +25,19 @@ 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,
|
||||
@@ -47,7 +52,21 @@ from esphome.types import ConfigType
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
AUTO_LOAD = ["json", "web_server_base"]
|
||||
|
||||
def AUTO_LOAD() -> list[str]:
|
||||
# No config parameter: a dynamic auto-load would satisfy ota.web_server's dependency too late
|
||||
auto_load = ["json", "web_server_base"]
|
||||
# The AP mode DNS server needs socket; CORE.raw_config already has a wifi block from a package
|
||||
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
|
||||
|
||||
|
||||
DOMAIN = "web_server"
|
||||
|
||||
AUTH_TYPE_BASIC = "basic"
|
||||
@@ -211,9 +230,6 @@ 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
|
||||
@@ -340,6 +356,108 @@ 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 _captive_portal_configured(full_config: ConfigType) -> bool:
|
||||
# Imported here: captive_portal pulls in wifi, which sensor (through web_server) must not
|
||||
from esphome.components.captive_portal import DOMAIN as CAPTIVE_PORTAL_DOMAIN
|
||||
|
||||
return CAPTIVE_PORTAL_DOMAIN in full_config
|
||||
|
||||
|
||||
def serve_captive(config: ConfigType, full_config: ConfigType) -> bool:
|
||||
"""Serve the embedded interface as a captive portal while the AP is up, unless captive_portal
|
||||
owns that role. Port 80 only: the OS probes and the DHCP portal URI never use another port."""
|
||||
wifi_config = full_config.get(CONF_WIFI)
|
||||
return (
|
||||
not _captive_portal_configured(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)
|
||||
ap_only = wifi_is_ap_only(wifi_config)
|
||||
if captive:
|
||||
web_server_base.consume_captive_dns_sockets(config, DOMAIN)
|
||||
# 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 ap_only else "fallback ",
|
||||
)
|
||||
# An embedded interface on an AP (AP only, or a fallback with local: true) that is not
|
||||
# on port 80 cannot be a captive portal; say so, unless captive_portal has that role.
|
||||
if (
|
||||
local
|
||||
and config[CONF_PORT] != 80
|
||||
and wifi_config is not None
|
||||
and CONF_AP in wifi_config
|
||||
and not _captive_portal_configured(full_config)
|
||||
):
|
||||
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(
|
||||
"web_server uses port %d, so the interface cannot open automatically on the "
|
||||
"%saccess point (captive portal detection only works on port 80); open "
|
||||
"http://%s:%d/ manually, or remove 'port:' to use 80.",
|
||||
config[CONF_PORT],
|
||||
"" if ap_only else "fallback ",
|
||||
ap_ip,
|
||||
config[CONF_PORT],
|
||||
)
|
||||
if ap_only and 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",
|
||||
)
|
||||
|
||||
|
||||
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)
|
||||
@@ -459,8 +577,12 @@ async def to_code(config: ConfigType) -> None:
|
||||
# include_internal_ is false in C++; only emit the setter to turn it on.
|
||||
if config[CONF_INCLUDE_INTERNAL]:
|
||||
cg.add(var.set_include_internal(True))
|
||||
if CONF_LOCAL in config and config[CONF_LOCAL]:
|
||||
if serve_local(config, CORE.config.get(CONF_WIFI)):
|
||||
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")
|
||||
|
||||
|
||||
@@ -45,6 +45,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"
|
||||
@@ -388,7 +392,15 @@ void DeferredUpdateEventSourceList::on_client_disconnect_(DeferredUpdateEventSou
|
||||
}
|
||||
#endif
|
||||
|
||||
WebServer::WebServer(web_server_base::WebServerBase *base) : base_(base) {}
|
||||
#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
|
||||
}
|
||||
|
||||
// Kept out of the callers so the 64 bit division is emitted once
|
||||
__attribute__((noinline)) static uint32_t uptime_seconds() { return static_cast<uint32_t>(millis_64() / 1000); }
|
||||
@@ -429,6 +441,11 @@ 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
|
||||
|
||||
@@ -444,16 +461,54 @@ void WebServer::setup() {
|
||||
});
|
||||
}
|
||||
void WebServer::loop() {
|
||||
// No SSE clients connected; stop looping until a new client connects via
|
||||
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
|
||||
// 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 (!keep_looping)
|
||||
this->disable_loop();
|
||||
}
|
||||
|
||||
#ifdef USE_WEBSERVER_CAPTIVE
|
||||
void WebServer::start_captive() {
|
||||
// Avoid repeating the log and enable_loop on a second start
|
||||
if (this->dns_.is_running())
|
||||
return;
|
||||
network::IPAddress ip = wifi::global_wifi_component->wifi_soft_ap_ip();
|
||||
char ip_buf[network::IP_ADDRESS_BUFFER_SIZE];
|
||||
if (!this->dns_.start(ip)) {
|
||||
ESP_LOGW(TAG, "AP mode: captive DNS failed to start; open http://%s/ manually", ip.str_to(ip_buf));
|
||||
return;
|
||||
}
|
||||
this->enable_loop();
|
||||
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 while the AP is up: 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 ip_buf[network::IP_ADDRESS_BUFFER_SIZE];
|
||||
wifi::global_wifi_component->wifi_soft_ap_ip().str_to(ip_buf);
|
||||
// Captive mode requires port 80 (enforced at validation), so no port suffix
|
||||
char location[7 + network::IP_ADDRESS_BUFFER_SIZE + 1];
|
||||
buf_append_printf(location, sizeof(location), 0, "http://%s/", ip_buf);
|
||||
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) {
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
#include "esphome/components/json/json_util.h"
|
||||
#include "esphome/components/web_server_base/web_server_base.h"
|
||||
#include "esphome/components/web_server_base/captive_dns.h"
|
||||
#ifdef USE_WEBSERVER
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/core/entity_base.h"
|
||||
@@ -277,6 +278,14 @@ class WebServer final : public Component, public AsyncWebHandler {
|
||||
/// Handle an index request under '/'.
|
||||
void handle_index_request(AsyncWebServerRequest *request);
|
||||
|
||||
#ifdef USE_WEBSERVER_CAPTIVE
|
||||
/// AP mode: DNS answers every name with the AP address and unknown URLs redirect to the interface.
|
||||
/// wifi calls start before setup(), so it may touch nothing but dns_ (enable_loop is a no-op then).
|
||||
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();
|
||||
|
||||
@@ -606,6 +615,10 @@ class WebServer final : public Component, public AsyncWebHandler {
|
||||
#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};
|
||||
@@ -704,5 +717,9 @@ class WebServer final : public Component, public AsyncWebHandler {
|
||||
#endif
|
||||
};
|
||||
|
||||
#ifdef USE_WEBSERVER_CAPTIVE
|
||||
extern WebServer *global_web_server; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
#endif
|
||||
|
||||
} // namespace esphome::web_server
|
||||
#endif
|
||||
|
||||
@@ -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
|
||||
@@ -27,6 +28,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: ConfigType) -> ConfigType:
|
||||
"""Register the shared listening socket for the HTTP server.
|
||||
|
||||
@@ -82,3 +99,15 @@ 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,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
#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:
|
||||
/// Returns false when the DNS server could not start; nothing is kept, so is_running() stays false.
|
||||
bool start(const network::IPAddress &ip) {
|
||||
if (this->dns_server_ != nullptr)
|
||||
return true;
|
||||
auto server = make_unique<DNSServer>();
|
||||
#if defined(USE_ESP32)
|
||||
const bool started = server->start(ip);
|
||||
#elif defined(USE_ARDUINO)
|
||||
server->setErrorReplyCode(DNSReplyCode::NoError);
|
||||
const bool started = server->start(53, ESPHOME_F("*"), ip);
|
||||
#endif
|
||||
if (!started)
|
||||
return false;
|
||||
this->dns_server_ = std::move(server);
|
||||
return true;
|
||||
}
|
||||
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
|
||||
+9
-8
@@ -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 {
|
||||
|
||||
ESPHOME_LOG_TAG(TAG, "captive_portal.dns");
|
||||
ESPHOME_LOG_TAG(TAG, "web_server_base.dns");
|
||||
|
||||
// DNS constants
|
||||
static constexpr uint16_t DNS_PORT = 53;
|
||||
@@ -46,7 +46,7 @@ struct DNSAnswer {
|
||||
uint32_t ip_addr;
|
||||
} __attribute__((packed));
|
||||
|
||||
void DNSServer::start(const network::IPAddress &ip) {
|
||||
bool DNSServer::start(const network::IPAddress &ip) {
|
||||
this->server_ip_ = ip;
|
||||
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
|
||||
char ip_buf[network::IP_ADDRESS_BUFFER_SIZE];
|
||||
@@ -57,7 +57,7 @@ void DNSServer::start(const network::IPAddress &ip) {
|
||||
this->socket_ = socket::socket_ip_loop_monitored(SOCK_DGRAM, IPPROTO_UDP).release();
|
||||
if (this->socket_ == nullptr) {
|
||||
ESP_LOGE(TAG, "Socket create failed");
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Set socket options
|
||||
@@ -72,9 +72,10 @@ void DNSServer::start(const network::IPAddress &ip) {
|
||||
if (err != 0) {
|
||||
ESP_LOGE(TAG, "Bind failed: %d", errno);
|
||||
this->destroy_socket_();
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
ESP_LOGV(TAG, "Bound to port %d", DNS_PORT);
|
||||
return true;
|
||||
}
|
||||
|
||||
void DNSServer::stop() {
|
||||
@@ -202,6 +203,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)
|
||||
+8
-5
@@ -1,15 +1,18 @@
|
||||
#pragma once
|
||||
#ifdef USE_ESP32
|
||||
#include "esphome/core/defines.h"
|
||||
// ESP32 backend for CaptiveDNS: answers every A query with the access point address.
|
||||
#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:
|
||||
void start(const network::IPAddress &ip);
|
||||
/// Returns false when the socket could not be created or bound.
|
||||
bool start(const network::IPAddress &ip);
|
||||
void stop();
|
||||
void process_next_request();
|
||||
|
||||
@@ -27,6 +30,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)
|
||||
@@ -335,9 +335,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);
|
||||
}
|
||||
|
||||
@@ -129,7 +129,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());
|
||||
|
||||
@@ -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_BLE
|
||||
#include "esphome/components/improv_ble/improv_ble_component.h"
|
||||
@@ -653,12 +656,12 @@ void WiFiComponent::setup() {
|
||||
#endif
|
||||
|
||||
#if defined(USE_PROVISIONING) && defined(USE_WIFI_AP)
|
||||
// The access point is a provisioning surface: once the provisioning window has
|
||||
// closed, shut it down (mirrors the teardown done on a successful connection).
|
||||
// The captive portal registers its own closed-callback, and the fallback block
|
||||
// in loop() is gated so neither is started again afterwards.
|
||||
// The access point and the portals on it are provisioning surfaces: once the window has
|
||||
// closed, end them (mirrors the teardown on a successful connection). The fallback block in
|
||||
// loop() is gated so none of them is started again afterwards.
|
||||
if (provisioning::global_provisioning_manager != nullptr) {
|
||||
provisioning::global_provisioning_manager->add_on_closed_callback([this]() {
|
||||
this->end_ap_portal_(); // no-op when nothing is active, so not tied to ap_setup_
|
||||
if (this->ap_setup_) {
|
||||
ESP_LOGD(TAG, "Provisioning window closed; disabling AP");
|
||||
#ifdef USE_WIFI_AP_EXCLUSIVE
|
||||
@@ -778,15 +781,13 @@ void WiFiComponent::start() {
|
||||
ESP_LOGV(TAG, "Setting Output Power Option failed");
|
||||
}
|
||||
#ifdef USE_CAPTIVE_PORTAL
|
||||
if (captive_portal::global_captive_portal != nullptr) {
|
||||
// Where the radio scans alongside the AP, the portal can list networks.
|
||||
if (!WIFI_AP_EXCLUSIVE) {
|
||||
this->wifi_sta_pre_setup_();
|
||||
this->start_scanning();
|
||||
}
|
||||
captive_portal::global_captive_portal->start();
|
||||
// Where the radio scans alongside the AP, the portal can list networks.
|
||||
if (captive_portal::global_captive_portal != nullptr && !WIFI_AP_EXCLUSIVE) {
|
||||
this->wifi_sta_pre_setup_();
|
||||
this->start_scanning();
|
||||
}
|
||||
#endif
|
||||
this->start_ap_portal_();
|
||||
#endif // USE_WIFI_AP
|
||||
}
|
||||
#ifdef USE_IMPROV_BLE
|
||||
@@ -847,8 +848,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_improv_ble_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_improv_ble_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
|
||||
@@ -944,16 +945,15 @@ void WiFiComponent::loop() {
|
||||
this->ap_exclusive_changed_ = now;
|
||||
#endif
|
||||
this->setup_ap_config_();
|
||||
// Where the AP runs on its own, a portal with no AP behind it would only stretch the cooldowns.
|
||||
if (!WIFI_AP_EXCLUSIVE || this->ap_setup_) {
|
||||
#ifdef USE_CAPTIVE_PORTAL
|
||||
// Where the AP runs on its own, a portal with no AP behind it would
|
||||
// only stretch the cooldowns.
|
||||
if (captive_portal::global_captive_portal != nullptr && (!WIFI_AP_EXCLUSIVE || this->ap_setup_)) {
|
||||
// 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
|
||||
@@ -1118,11 +1118,7 @@ void WiFiComponent::pause_exclusive_ap_() {
|
||||
#endif
|
||||
|
||||
void WiFiComponent::disable_ap_() {
|
||||
#ifdef USE_CAPTIVE_PORTAL
|
||||
if (this->is_captive_portal_active_()) {
|
||||
captive_portal::global_captive_portal->end();
|
||||
}
|
||||
#endif
|
||||
this->end_ap_portal_();
|
||||
ESP_LOGD(TAG, "Disabling AP");
|
||||
this->wifi_mode_({}, false);
|
||||
}
|
||||
@@ -2073,10 +2069,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_improv_ble_active_()) {
|
||||
// without disrupting the portal/improv connection
|
||||
if (!this->is_ap_portal_active_() && !this->is_improv_ble_active_()) {
|
||||
this->restart_adapter();
|
||||
} else {
|
||||
// Even when skipping full restart, disconnect to clear driver state
|
||||
@@ -2335,6 +2331,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_();
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
bool WiFiComponent::is_improv_ble_active_() {
|
||||
#ifdef USE_IMPROV_BLE
|
||||
return improv_ble::global_improv_component != nullptr && improv_ble::global_improv_component->is_active();
|
||||
|
||||
@@ -713,7 +713,7 @@ class WiFiComponent final : public Component {
|
||||
protected:
|
||||
#ifdef USE_WIFI_AP
|
||||
void setup_ap_config_();
|
||||
/// End the captive portal and turn the AP off.
|
||||
/// End the portals and turn the AP off.
|
||||
void disable_ap_();
|
||||
#ifdef USE_WIFI_AP_EXCLUSIVE
|
||||
/// Drop the fallback AP so the networks can be tried; it comes back after ap_timeout.
|
||||
@@ -832,6 +832,10 @@ 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_();
|
||||
void start_ap_portal_();
|
||||
void end_ap_portal_();
|
||||
bool is_improv_ble_active_();
|
||||
|
||||
#ifdef USE_WIFI_FAST_CONNECT
|
||||
|
||||
@@ -1334,21 +1334,18 @@ 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) {
|
||||
// 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
|
||||
network::IPAddress(&info.ip).str_to(captive_portal_uri + 7);
|
||||
err = esp_netif_dhcps_option(s_ap_netif, ESP_NETIF_OP_SET, ESP_NETIF_CAPTIVEPORTAL_URI, captive_portal_uri,
|
||||
strlen(captive_portal_uri));
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGV(TAG, "Failed to set DHCP captive portal URI: %s", esp_err_to_name(err));
|
||||
} else {
|
||||
ESP_LOGV(TAG, "DHCP Captive Portal URI set to: %s", captive_portal_uri);
|
||||
}
|
||||
#if (defined(USE_CAPTIVE_PORTAL) || defined(USE_WEBSERVER_CAPTIVE)) && ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 4, 0)
|
||||
// DHCP option 114 (captive portal URI) lets clients find the portal; either define means one exists
|
||||
// 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
|
||||
network::IPAddress(&info.ip).str_to(captive_portal_uri + 7);
|
||||
err = esp_netif_dhcps_option(s_ap_netif, ESP_NETIF_OP_SET, ESP_NETIF_CAPTIVEPORTAL_URI, captive_portal_uri,
|
||||
strlen(captive_portal_uri));
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGV(TAG, "Failed to set DHCP captive portal URI: %s", esp_err_to_name(err));
|
||||
} else {
|
||||
ESP_LOGV(TAG, "DHCP Captive Portal URI set to: %s", captive_portal_uri);
|
||||
}
|
||||
#endif
|
||||
|
||||
|
||||
@@ -460,6 +460,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
|
||||
@@ -597,6 +598,7 @@
|
||||
#define USE_WEBSERVER
|
||||
#define USE_WEBSERVER_AUTH
|
||||
#define USE_WEBSERVER_AUTH_DIGEST
|
||||
#define USE_WEBSERVER_CAPTIVE
|
||||
#define USE_WEBSERVER_PORT 80 // NOLINT
|
||||
#endif
|
||||
|
||||
@@ -656,6 +658,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,18 @@
|
||||
# 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, and when the
|
||||
# provisioning window closes) paths.
|
||||
provisioning:
|
||||
timeout: 1min
|
||||
|
||||
api:
|
||||
encryption:
|
||||
|
||||
wifi:
|
||||
ssid: MySSID
|
||||
password: password1
|
||||
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
|
||||
@@ -0,0 +1,2 @@
|
||||
packages:
|
||||
web_server: !include common-ap-fallback.yaml
|
||||
@@ -0,0 +1,2 @@
|
||||
packages:
|
||||
web_server: !include common-ap-mode.yaml
|
||||
@@ -0,0 +1,2 @@
|
||||
packages:
|
||||
web_server: !include common-ap-mode.yaml
|
||||
@@ -0,0 +1,172 @@
|
||||
"""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
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("web_server_config", "wifi_config", "expected"),
|
||||
[
|
||||
# AP only: local is implied, so the port alone blocks captive mode.
|
||||
({CONF_VERSION: 2, CONF_PORT: 8080}, AP_ONLY, "on the access point"),
|
||||
# Fallback AP with local: true opted into captive mode; say why it is skipped.
|
||||
(
|
||||
{CONF_VERSION: 2, CONF_PORT: 8080, CONF_LOCAL: True},
|
||||
AP_FALLBACK,
|
||||
"on the fallback access point",
|
||||
),
|
||||
# Fallback AP without local: hosted page, nothing was promised.
|
||||
({CONF_VERSION: 2, CONF_PORT: 8080}, AP_FALLBACK, None),
|
||||
],
|
||||
)
|
||||
def test_final_validate_ap_mode_warns_for_non_default_port(
|
||||
web_server_config: dict,
|
||||
wifi_config: dict,
|
||||
expected: str | None,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Captive portal detection needs port 80; other ports get a hint, not captive mode."""
|
||||
token = fv.full_config.set(
|
||||
{"web_server": web_server_config, CONF_WIFI: wifi_config}
|
||||
)
|
||||
try:
|
||||
with caplog.at_level(logging.WARNING):
|
||||
_final_validate_ap_mode(web_server_config)
|
||||
finally:
|
||||
fv.full_config.reset(token)
|
||||
if expected is None:
|
||||
assert "cannot open automatically" not in caplog.text
|
||||
return
|
||||
assert expected 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
|
||||
Reference in New Issue
Block a user