mirror of
https://github.com/esphome/esphome.git
synced 2026-10-05 02:21:30 +00:00
[web_server] Self contained AP mode: embed the interface and act as captive portal
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
+5
-5
@@ -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)
|
||||
+8
-4
@@ -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);
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user