Compare commits

..
Author SHA1 Message Date
J. Nick Koston 8f280abdbd Merge remote-tracking branch 'origin/dev' into web-server-offline-hint 2026-09-02 11:34:21 +02:00
esphome[bot] da16c01351 [ci] Refresh integration test durations (#18944) 2026-09-02 09:32:47 +00:00
Jesse Hills 6099ac7b53 [core] Document C++ conventions in AGENTS.md that reviews keep catching (#18941) 2026-09-02 11:17:22 +02:00
J. Nick Koston 8a88a3f576 Merge remote-tracking branch 'origin/dev' into web-server-offline-hint 2026-08-26 19:48:45 -05:00
J. Nick Koston 82431641cc Merge remote-tracking branch 'origin/web-server-offline-hint' into web-server-offline-hint 2026-08-25 12:42:27 -05:00
J. Nick Koston f79e9f9cb1 Merge remote-tracking branch 'origin/dev' into web-server-offline-hint
# Conflicts:
#	esphome/components/web_server/__init__.py
#	esphome/components/web_server_base/__init__.py
2026-08-25 12:41:34 -05:00
J. Nick Koston f17fd9fcb3 Merge remote-tracking branch 'origin/dev' into web-server-offline-hint 2026-08-23 18:33:35 -05:00
J. Nick Koston 07f091059c [web_server] Match the blank page remedy to the actual cause 2026-08-19 17:23:25 -05:00
J. Nick Koston 2807a6932b Merge branch 'dev' into web-server-offline-hint 2026-08-19 16:49:33 -05:00
J. Nick Koston ec09ce636c [web_server] Announce captive mode for explicit local: true too 2026-08-19 16:48:16 -05:00
J. Nick Koston 7f437aa680 [web_server] Surface implied behavior changes and honor manual_ip in the port warning 2026-08-19 16:09:27 -05:00
J. Nick Koston f8a60d0259 [web_server] Address review: tighter socket auto-load, flash cost in the log, fallback compile test 2026-08-19 15:26:11 -05:00
J. Nick Koston 50f50aa7e0 [web_server] Limit captive mode to port 80 and hint at the manual URL otherwise 2026-08-19 15:22:04 -05:00
J. Nick Koston 676ecae469 [wifi][web_server] Guard AP portal helpers with USE_WIFI_AP and apply review cleanups 2026-08-19 14:40:35 -05:00
J. Nick Koston 78258dc554 [wifi] Declare the AP portal helpers for builds without an access point 2026-08-19 14:30:46 -05:00
J. Nick Koston 55180938ca [web_server] Keep AUTO_LOAD static so web_server_base loads before ota.web_server 2026-08-19 14:21:17 -05:00
J. Nick Koston d88a95c3f6 [web_server] Share the captive DNS server, use the not-found fallback for probes, fold portal hooks in wifi 2026-08-19 14:15:06 -05:00
J. Nick Koston 4dad932cd8 [web_server] Self contained AP mode: embed the interface and act as captive portal 2026-08-19 14:00:43 -05:00
J. Nick Koston 3b8b90b2cd [web_server] Embed the interface and warn for AP only WiFi instead of hinting in the page 2026-08-19 13:42:52 -05:00
J. Nick Koston 9df8505c8d [web_server] Show the offline hint on a timer so a stalled download is covered too 2026-08-19 13:25:51 -05:00
J. Nick Koston 427b83dc0a [web_server] Move the AP only warning into web_server and emit the script tag once 2026-08-19 13:07:19 -05:00
J. Nick Koston deb641d413 [wifi] Warn when an AP only config serves the hosted web_server page 2026-08-19 13:02:59 -05:00
J. Nick Koston 0b3e399588 [web_server] Shorten the offline hint 2026-08-19 12:59:40 -05:00
J. Nick Koston 33d9adbef1 [web_server] Only add the offline hint without captive_portal 2026-08-19 12:57:58 -05:00
J. Nick Koston 88d6ded5f3 [web_server] Show a hint when the hosted web interface cannot be downloaded 2026-08-19 12:51:51 -05:00
26 changed files with 822 additions and 435 deletions
+96 -2
View File
@@ -44,6 +44,16 @@ This document provides essential context for AI models interacting with this pro
## 4. Coding Conventions & Style Guide
**Read the developer documentation before writing a component.** https://developers.esphome.io covers the
component lifecycle, the main loop, and the reasoning behind the rules below in far more depth than this
file does, and it is the authority when they disagree. The most useful starting points:
* https://developers.esphome.io/architecture/components/ - component lifecycle, `setup()`, `loop()`,
setup priorities, and how a component is registered.
* https://developers.esphome.io/architecture/components/advanced/ - choosing between `loop()`,
`set_interval`, `set_timeout` and `defer`; waking the loop from another thread; the RAM cost of each.
* https://developers.esphome.io/contributing/code/ - contribution rules, public API and breaking changes.
* **Formatting:**
* **Python:** Uses `ruff` and `flake8` for linting and formatting. Configuration is in `pyproject.toml`.
* **C++:** Uses `clang-format` for formatting. Configuration is in `.clang-format`.
@@ -142,6 +152,47 @@ This document provides essential context for AI models interacting with this pro
* **Indentation:** Use spaces (two per indentation level), not tabs
* **Type aliases:** Prefer `using type_t = int;` over `typedef int type_t;`
* **Line length:** Wrap lines at no more than 120 characters
* **Timing in `loop()`:** Never call `millis()` in a `loop()` body. The current tick's timestamp is
already cached - use `App.get_loop_component_start_time()` (from `esphome/core/application.h`).
Only reach for `millis()` when you genuinely need sub-tick resolution inside a long operation.
* **The main loop runs every 16 ms.** A rate-limit gate shorter than that does nothing: the check
passes on essentially every pass of the loop, so it costs a comparison and buys nothing. Pick an
interval comfortably coarser than 16 ms, or drop the gate entirely and accept running every loop.
```cpp
// Bad - a 10ms gate against a 16ms loop never holds anything back
static constexpr uint32_t POLL_INTERVAL_MS = 10;
const uint32_t now = millis();
if (now - this->last_poll_ < POLL_INTERVAL_MS)
return;
this->last_poll_ = now;
```
```cpp
// Good - an interval that actually rate limits, off the cached timestamp
static constexpr uint32_t POLL_INTERVAL_MS = 100;
const uint32_t now = App.get_loop_component_start_time();
if (now - this->last_poll_ < POLL_INTERVAL_MS)
return;
this->last_poll_ = now;
```
Pick the primitive by cadence: under 250 ms use a gated `loop()`; 500 ms and above use
`set_interval`. Full reasoning, including why `set_interval` costs more below 500 ms:
https://developers.esphome.io/architecture/components/advanced/#quick-rule-of-thumb
* **Don't override a default with the same value:** if a base class method already returns what you
want, do not override it. `Component::get_setup_priority()` returns `setup_priority::DATA`, so a
component that wants `DATA` should simply leave it alone.
```cpp
// Bad - this is exactly what the base class already does
float get_setup_priority() const override { return setup_priority::DATA; }
```
* **Logging string literals:** wrap literals passed as `%s` arguments in `LOG_STR_LITERAL()` so they
can be stored in flash rather than RAM.
```cpp
// Bad
ESP_LOGV(TAG, "Key %u %s", key, pressed ? "pressed" : "released");
// Good
ESP_LOGV(TAG, "Key %u %s", key, pressed ? LOG_STR_LITERAL("pressed") : LOG_STR_LITERAL("released"));
```
* **Constructor parameters vs setters:** Component properties that are both **required** and **invariant**
(never change after construction) should be constructor parameters rather than set via setter methods.
This makes the dependency explicit and prevents use of the object in an incompletely-initialized state.
@@ -562,6 +613,33 @@ This document provides essential context for AI models interacting with this pro
Use `cg.add_define("MAX_SERVICES", count)` to set the size from Python configuration.
Like `std::array` but with vector-like API (`push_back()`, `size()`) and no STL reallocation code.
**Listener and child-entity registration lists are the most common case, and the most commonly
missed.** A `register_*()` method called once per child at code generation time has a count that
is known at compile time, so it should never be a `std::vector`. Use `cg.slot_counter()`: it
returns a function that each consumer calls once per slot it will occupy, and after every
`to_code` has run it emits the define with the final count. When nothing registers, no define is
emitted and the storage plus its registration method compile out entirely.
```python
# hub component's __init__.py
_request_listener_slot = cg.slot_counter("MY_COMPONENT_LISTENER_COUNT")
async def register_listener(hub: MockObj, var: MockObj) -> None:
_request_listener_slot()
cg.add(hub.register_listener(var))
```
```cpp
#ifdef MY_COMPONENT_LISTENER_COUNT
void register_listener(MyComponentListener *listener);
#endif
protected:
#ifdef MY_COMPONENT_LISTENER_COUNT
StaticVector<MyComponentListener *, MY_COMPONENT_LISTENER_COUNT> listeners_;
#endif
```
Request slots from `to_code`, not from a job that runs after `CoroPriority.FINAL` - a late
request raises rather than silently undercounting.
3. **Runtime-known sizes:** Use `FixedVector` from `esphome/core/helpers.h` when the size is only known at runtime initialization.
```cpp
// Bad - generates STL realloc code (_M_realloc_insert)
@@ -599,9 +677,25 @@ This document provides essential context for AI models interacting with this pro
```
Linear search on small datasets (1-16 elements) is often faster than hashing/tree overhead, but this depends on lookup frequency and access patterns. For frequent lookups in hot code paths, the O(1) vs O(n) complexity difference may still matter even for small datasets. `std::vector` with simple structs is usually fine—it's the heavy containers (`map`, `set`, `unordered_map`) that should be avoided for small datasets unless profiling shows otherwise.
5. **Avoid `std::deque`:** It allocates in 512-byte blocks regardless of element size, guaranteeing at least 512 bytes of RAM usage immediately. This is a major source of crashes on memory-constrained devices.
5. **Strings set once from configuration:** Use `StringRef` (`esphome/core/string_ref.h`) rather than
`std::string`. Code generation passes a string literal that lives in flash for the life of the
program, so storing a `std::string` copies it onto the heap for nothing. `StringRef` is a
non-owning pointer plus length; it does not copy, and it must only ever refer to storage that
outlives it (a string literal, or a buffer owned elsewhere).
```cpp
// Bad - heap copy of a literal that is already in flash
void set_keys(std::string keys) { this->keys_ = std::move(keys); }
std::string keys_;
```
```cpp
// Good - no allocation
void set_keys(const char *keys) { this->keys_ = StringRef(keys); }
StringRef keys_;
```
6. **Detection:** Look for these patterns in compiler output:
6. **Avoid `std::deque`:** It allocates in 512-byte blocks regardless of element size, guaranteeing at least 512 bytes of RAM usage immediately. This is a major source of crashes on memory-constrained devices.
7. **Detection:** Look for these patterns in compiler output:
- Large code sections with STL symbols (vector, map, set)
- `alloc`, `realloc`, `dealloc` in symbol names
- `_M_realloc_insert`, `_M_default_append` (vector reallocation)
+2 -26
View File
@@ -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
@@ -76,17 +74,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, "captive_portal")
FINAL_VALIDATE_SCHEMA = _final_validate
@@ -106,16 +94,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()
@@ -102,17 +102,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)
+116 -5
View File
@@ -12,6 +12,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,
@@ -23,15 +24,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,
@@ -46,7 +51,23 @@ from esphome.types import ConfigType
_LOGGER = logging.getLogger(__name__)
AUTO_LOAD = ["json", "web_server_base"]
def AUTO_LOAD() -> list[str]:
# No config parameter on purpose: that would make this a late (dynamic) auto-load and
# ota.web_server's dependency on web_server_base would not be satisfied in time.
auto_load = ["json", "web_server_base"]
# The AP mode DNS server (web_server_base/dns_server_esp32_idf) uses socket; only
# configs with a WiFi access point can end up in AP mode. CORE.raw_config is set
# after package merging, so a wifi block from a package is visible here.
wifi = CORE.raw_config.get(CONF_WIFI) if CORE.raw_config else None
if (
CORE.is_esp32
and wifi is not None
and (not isinstance(wifi, dict) or CONF_AP in wifi)
):
auto_load.append("socket")
return auto_load
AUTH_TYPE_BASIC = "basic"
AUTH_TYPE_DIGEST = "digest"
@@ -205,9 +226,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
@@ -334,6 +352,95 @@ async def add_entity_config(entity: MockObj, config: ConfigType) -> None:
)
def wifi_is_ap_only(wifi_config: ConfigType | None) -> bool:
"""AP only: an access point and no network to join, so the device is only ever reached
through its own AP."""
return (
wifi_config is not None
and CONF_AP in wifi_config
and not wifi_config.get(CONF_NETWORKS)
)
def serve_local(config: ConfigType, wifi_config: ConfigType | None) -> bool:
"""Embed the interface unless ``local:`` says otherwise; AP only WiFi has no internet
for the hosted page. Version 1 has no local mode."""
if (local := config.get(CONF_LOCAL)) is not None:
return local
return config[CONF_VERSION] != 1 and wifi_is_ap_only(wifi_config)
def serve_captive(config: ConfigType, full_config: ConfigType) -> bool:
"""web_server runs its own captive portal while the AP is up: embedded interface plus
an access point, unless captive_portal (which owns that role) is configured. Only on
port 80: the OS captive portal probes and the DHCP portal URI always use port 80, so
a portal on another port could never be discovered."""
wifi_config = full_config.get(CONF_WIFI)
return (
"captive_portal" not in full_config
and config[CONF_PORT] == 80
and wifi_config is not None
and CONF_AP in wifi_config
and serve_local(config, wifi_config)
)
def _final_validate_ap_mode(config: ConfigType) -> None:
full_config = fv.full_config.get()
wifi_config = full_config.get(CONF_WIFI)
captive = serve_captive(config, full_config)
local = serve_local(config, wifi_config)
if captive:
web_server_base.consume_captive_dns_sockets(config, "web_server")
# Surface behavior that the config does not spell out.
if local and CONF_LOCAL not in config:
_LOGGER.info(
"WiFi is AP only: embedding the web interface in the firmware "
"(local: true, roughly 13 KB of flash for version 2, 78 KB for version 3)%s. "
"Set 'local: false' to load it from the internet instead.",
" and serving it as a captive portal on the access point"
if captive
else "",
)
elif captive:
_LOGGER.info(
"web_server will act as a captive portal while the %saccess point is active.",
"" if wifi_is_ap_only(wifi_config) else "fallback ",
)
if not wifi_is_ap_only(wifi_config):
return
if not local:
_LOGGER.warning(
"WiFi is AP only and the web_server interface is loaded from the internet, "
"which browsers on the access point usually cannot reach; the page stays "
"blank. %s so the interface is embedded in the firmware.",
"Remove 'local: false'"
if config.get(CONF_LOCAL) is False
else "Migrate to version 2 or 3",
)
elif config[CONF_PORT] != 80:
ap_ip = "192.168.4.1"
if (manual_ip := wifi_config[CONF_AP].get(CONF_MANUAL_IP)) is not None:
ap_ip = str(manual_ip[CONF_STATIC_IP])
_LOGGER.warning(
"WiFi is AP only and web_server uses port %d. The interface cannot open "
"automatically on the access point (captive portal detection only works on "
"port 80); open http://%s:%d/ manually, or remove 'port:' to use 80.",
config[CONF_PORT],
ap_ip,
config[CONF_PORT],
)
def _final_validate(config: ConfigType) -> None:
# Called one after the other rather than via cv.All: these return None.
_final_validate_sorting(config)
_final_validate_ap_mode(config)
FINAL_VALIDATE_SCHEMA = _final_validate
def build_index_html(config: ConfigType) -> str:
html = "<!DOCTYPE html><html><head><meta charset=UTF-8><link rel=icon href=data:>"
css_include = config.get(CONF_CSS_INCLUDE)
@@ -434,8 +541,12 @@ async def to_code(config: ConfigType) -> None:
with path.open(encoding="utf-8") as js_file:
add_resource_as_progmem("JS_INCLUDE", js_file.read())
cg.add(var.set_include_internal(config[CONF_INCLUDE_INTERNAL]))
if 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")
+56 -3
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"
@@ -334,7 +338,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
}
#ifdef USE_WEBSERVER_CSS_INCLUDE
void WebServer::set_css_include(const char *css_include) { this->css_include_ = css_include; }
@@ -380,6 +392,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
@@ -395,16 +412,52 @@ 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() {
// CaptiveDNS::start() no-ops too; this guard just avoids repeating the log and enable_loop
if (this->dns_.is_running())
return;
network::IPAddress ip = wifi::global_wifi_component->wifi_soft_ap_ip();
this->dns_.start(ip);
this->enable_loop();
char ip_buf[network::IP_ADDRESS_BUFFER_SIZE];
ESP_LOGI(TAG, "AP mode: serving the web interface as captive portal at http://%s/", ip.str_to(ip_buf));
}
void WebServer::end_captive() { this->dns_.stop(); }
void WebServer::handle_not_found_(AsyncWebServerRequest *request) {
// OS captive portal probe (or any other unknown page) while the AP is up: send the browser
// to the real page. A redirect rather than the page itself, because the interface resolves
// its /events and REST paths relative to the page URL.
if (this->dns_.is_running() && request->method() == HTTP_GET) {
// Captive mode requires port 80 (enforced at validation), so no port suffix is needed.
char location[7 + network::IP_ADDRESS_BUFFER_SIZE + 1];
size_t pos = buf_append_str(location, sizeof(location), 0, "http://");
wifi::global_wifi_component->wifi_soft_ap_ip().str_to(location + pos);
buf_append_str(location, sizeof(location), strlen(location), "/");
request->redirect(location);
return;
}
request->send(404);
}
#endif
#ifdef USE_LOGGER
void WebServer::on_log(uint8_t level, const char *tag, const char *message, size_t message_len) {
(void) level;
@@ -4,6 +4,9 @@
#include "esphome/components/json/json_util.h"
#include "esphome/components/web_server_base/web_server_base.h"
#ifdef USE_WEBSERVER_CAPTIVE
#include "esphome/components/web_server_base/captive_dns.h"
#endif
#ifdef USE_WEBSERVER
#include "esphome/core/component.h"
#include "esphome/core/controller.h"
@@ -276,6 +279,18 @@ class WebServer final : public Controller, public Component, public AsyncWebHand
/// Handle an index request under '/'.
void handle_index_request(AsyncWebServerRequest *request);
#ifdef USE_WEBSERVER_CAPTIVE
/** AP mode: run a DNS server that answers every name with the AP address and redirect any
* unknown URL to the interface, so a phone joining the AP opens it through the OS captive
* portal check. Started and ended by the wifi component with the access point. start may run
* before setup() (wifi sets up first): safe because enable_loop() is a no-op before setup;
* nothing but the DNS server may be touched, in particular not base_ or the handlers.
*/
void start_captive();
void end_captive();
bool is_captive() const { return this->dns_.is_running(); }
#endif
/// Return the webserver configuration as JSON.
json::SerializationBuffer<> get_config_json();
@@ -597,6 +612,10 @@ class WebServer final : public Controller, public Component, public AsyncWebHand
#elif USE_ARDUINO
DeferredUpdateEventSourceList events_;
#endif
#ifdef USE_WEBSERVER_CAPTIVE
void handle_not_found_(AsyncWebServerRequest *request);
web_server_base::CaptiveDNS dns_;
#endif
#if USE_WEBSERVER_VERSION == 1
const char *css_url_{nullptr};
@@ -696,5 +715,9 @@ class WebServer final : public Controller, public Component, public AsyncWebHand
#endif
};
#ifdef USE_WEBSERVER_CAPTIVE
extern WebServer *global_web_server; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
#endif
} // namespace esphome::web_server
#endif
+30 -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
@@ -26,6 +27,22 @@ WebServerBase = web_server_base_ns.class_("WebServerBase")
CONF_WEB_SERVER_BASE_ID = "web_server_base_id"
def consume_captive_dns_sockets(config: ConfigType, name: str) -> None:
"""Register the sockets a captive portal needs on top of the shared HTTP server:
1 UDP socket for the DNS server and 3 TCP sockets for the OS captive portal probes,
which make several requests that linger in TIME_WAIT."""
from esphome.components import socket
socket.consume_sockets(3, name)(config)
socket.consume_sockets(1, name, socket.SocketType.UDP)(config)
def add_captive_dns_library() -> None:
"""Pull in the Arduino DNSServer library used by CaptiveDNS off ESP32."""
if CORE.using_arduino and (CORE.is_esp8266 or CORE.is_libretiny or CORE.is_rp2):
cg.add_library("DNSServer", None)
def _consume_web_server_base_sockets(config: ConfigType) -> ConfigType:
"""Register the shared listening socket for the HTTP server.
@@ -81,3 +98,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,58 @@
#pragma once
#include "esphome/core/defines.h"
// DNS server that answers every name with the access point address, so a phone joining the
// AP runs its captive portal check against the device. Shared by captive_portal and the
// web_server AP mode; hides the ESP32 (own implementation) vs Arduino (DNSServer library) split.
#if defined(USE_CAPTIVE_PORTAL) || defined(USE_WEBSERVER_CAPTIVE)
#include <memory>
#include "esphome/components/network/ip_address.h"
#include "esphome/core/helpers.h"
#include "esphome/core/progmem.h"
#if defined(USE_ESP32)
#include "dns_server_esp32_idf.h"
#elif defined(USE_ARDUINO)
#include <DNSServer.h>
#endif
namespace esphome::web_server_base {
// The server object only exists while running, so an idle owner (AP not up) pays one pointer.
class CaptiveDNS {
public:
void start(const network::IPAddress &ip) {
if (this->dns_server_ != nullptr)
return;
this->dns_server_ = make_unique<DNSServer>();
#if defined(USE_ESP32)
this->dns_server_->start(ip);
#elif defined(USE_ARDUINO)
this->dns_server_->setErrorReplyCode(DNSReplyCode::NoError);
this->dns_server_->start(53, ESPHOME_F("*"), ip);
#endif
}
void stop() {
if (this->dns_server_ == nullptr)
return;
this->dns_server_->stop();
this->dns_server_ = nullptr;
}
/// Answer one pending query; call from the owner's loop() while running.
void loop() {
if (this->dns_server_ == nullptr)
return;
#if defined(USE_ESP32)
this->dns_server_->process_next_request();
#elif defined(USE_ARDUINO)
this->dns_server_->processNextRequest();
#endif
}
bool is_running() const { return this->dns_server_ != nullptr; }
protected:
// ESP32: web_server_base::DNSServer from dns_server_esp32_idf.h; Arduino: the library class.
std::unique_ptr<DNSServer> dns_server_;
};
} // namespace esphome::web_server_base
#endif // USE_CAPTIVE_PORTAL || USE_WEBSERVER_CAPTIVE
@@ -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)
@@ -325,9 +325,9 @@ StringRef AsyncWebServerRequest::url_to(std::span<char, URL_BUF_SIZE> buffer) co
return StringRef(buffer.data(), decoded_len);
}
void AsyncWebServerRequest::redirect(const std::string &url) {
void AsyncWebServerRequest::redirect(const char *url) {
httpd_resp_set_status(*this, "302 Found");
httpd_resp_set_hdr(*this, "Location", url.c_str());
httpd_resp_set_hdr(*this, "Location", url);
httpd_resp_set_hdr(*this, "Connection", "close");
httpd_resp_send(*this, nullptr, 0);
}
@@ -126,7 +126,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());
+47 -16
View File
@@ -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"
@@ -741,9 +744,9 @@ void WiFiComponent::start() {
if (captive_portal::global_captive_portal != nullptr) {
this->wifi_sta_pre_setup_();
this->start_scanning();
captive_portal::global_captive_portal->start();
}
#endif
this->start_ap_portal_();
#endif // USE_WIFI_AP
}
#ifdef USE_IMPROV
@@ -804,8 +807,8 @@ void WiFiComponent::loop() {
this->check_connecting_finished(now);
break;
}
// Use longer cooldown when captive portal/improv is active to avoid disrupting user config
bool portal_active = this->is_captive_portal_active_() || this->is_esp32_improv_active_();
// Use longer cooldown when a portal/improv is active to avoid disrupting a user on the AP
bool portal_active = this->is_ap_portal_active_() || this->is_esp32_improv_active_();
uint32_t cooldown_duration = portal_active ? WIFI_COOLDOWN_WITH_AP_ACTIVE_MS : WIFI_COOLDOWN_DURATION_MS;
if (now - this->action_started_ > cooldown_duration) {
// After cooldown we either restarted the adapter because of
@@ -882,13 +885,11 @@ void WiFiComponent::loop() {
ESP_LOGI(TAG, "Starting fallback AP");
this->setup_ap_config_();
#ifdef USE_CAPTIVE_PORTAL
if (captive_portal::global_captive_portal != nullptr) {
// Reset so we force one full scan after captive portal starts
// (previous scans were filtered because captive portal wasn't active yet)
this->has_completed_scan_after_captive_portal_start_ = false;
captive_portal::global_captive_portal->start();
}
// Reset so we force one full scan after captive portal starts
// (previous scans were filtered because captive portal wasn't active yet)
this->has_completed_scan_after_captive_portal_start_ = false;
#endif
this->start_ap_portal_();
}
}
#endif // USE_WIFI_AP
@@ -1635,10 +1636,8 @@ void WiFiComponent::check_connecting_finished(uint32_t now) {
this->retry_phase_ = WiFiRetryPhase::INITIAL_CONNECT;
this->num_retried_ = 0;
if (this->has_ap()) {
#ifdef USE_CAPTIVE_PORTAL
if (this->is_captive_portal_active_()) {
captive_portal::global_captive_portal->end();
}
#ifdef USE_WIFI_AP
this->end_ap_portal_();
#endif
ESP_LOGD(TAG, "Disabling AP");
this->wifi_mode_({}, false);
@@ -1965,10 +1964,10 @@ bool WiFiComponent::transition_to_phase_(WiFiRetryPhase new_phase) {
break;
case WiFiRetryPhase::RESTARTING_ADAPTER:
// Skip actual adapter restart if captive portal/improv is active
// Skip actual adapter restart if a portal/improv is active
// This allows state machine to reset num_retried_ and trigger fresh scan
// without disrupting the captive portal/improv connection
if (!this->is_captive_portal_active_() && !this->is_esp32_improv_active_()) {
// without disrupting the portal/improv connection
if (!this->is_ap_portal_active_() && !this->is_esp32_improv_active_()) {
this->restart_adapter();
} else {
// Even when skipping full restart, disconnect to clear driver state
@@ -2227,6 +2226,38 @@ bool WiFiComponent::is_captive_portal_active_() {
return false;
#endif
}
bool WiFiComponent::is_ap_portal_active_() {
#ifdef USE_WEBSERVER_CAPTIVE
if (web_server::global_web_server->is_captive())
return true;
#endif
return this->is_captive_portal_active_();
}
#ifdef USE_WIFI_AP
// global_web_server needs no null check: codegen always instantiates WebServer when
// USE_WEBSERVER_CAPTIVE is defined, and the constructor assigns the global.
void WiFiComponent::start_ap_portal_() {
#ifdef USE_CAPTIVE_PORTAL
if (captive_portal::global_captive_portal != nullptr)
captive_portal::global_captive_portal->start();
#endif
#ifdef USE_WEBSERVER_CAPTIVE
web_server::global_web_server->start_captive();
#endif
}
void WiFiComponent::end_ap_portal_() {
#ifdef USE_CAPTIVE_PORTAL
if (this->is_captive_portal_active_())
captive_portal::global_captive_portal->end();
#endif
#ifdef USE_WEBSERVER_CAPTIVE
web_server::global_web_server->end_captive();
#endif
}
#endif // USE_WIFI_AP
bool WiFiComponent::is_esp32_improv_active_() {
#ifdef USE_IMPROV
return esp32_improv::global_improv_component != nullptr && esp32_improv::global_improv_component->is_active();
+6
View File
@@ -796,6 +796,12 @@ class WiFiComponent final : public Component {
network::IPAddress wifi_dns_ip_(int num);
bool is_captive_portal_active_();
/// captive_portal or the web_server AP mode is serving a user on the access point
bool is_ap_portal_active_();
#ifdef USE_WIFI_AP
void start_ap_portal_();
void end_ap_portal_();
#endif
bool is_esp32_improv_active_();
#ifdef USE_WIFI_FAST_CONNECT
@@ -1130,10 +1130,16 @@ bool WiFiComponent::wifi_ap_ip_config_(const optional<ManualIP> &manual_ip) {
return false;
}
#if defined(USE_CAPTIVE_PORTAL) && ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 4, 0)
// Configure DHCP Option 114 (Captive Portal URI) if captive portal is enabled
// This provides a standards-compliant way for clients to discover the captive portal
if (captive_portal::global_captive_portal != nullptr) {
#if (defined(USE_CAPTIVE_PORTAL) || defined(USE_WEBSERVER_CAPTIVE)) && ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 4, 0)
// Configure DHCP Option 114 (Captive Portal URI) if captive portal or the web_server AP
// mode is enabled. This provides a standards-compliant way for clients to discover the portal
#ifdef USE_WEBSERVER_CAPTIVE
// web_server AP mode always serves the portal when compiled in
const bool has_portal = true;
#else
const bool has_portal = captive_portal::global_captive_portal != nullptr;
#endif
if (has_portal) {
// Buffer must be static - dhcps_set_option_info stores pointer, doesn't copy
static char captive_portal_uri[24]; // "http://" (7) + IPv4 max (15) + null
memcpy(captive_portal_uri, "http://", 7); // NOLINT(bugprone-not-null-terminated-result) - str_to null-terminates
+3
View File
@@ -366,6 +366,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
@@ -479,6 +480,7 @@
#define USE_WEBSERVER
#define USE_WEBSERVER_AUTH
#define USE_WEBSERVER_AUTH_DIGEST
#define USE_WEBSERVER_CAPTIVE
#define USE_WEBSERVER_PORT 80 // NOLINT
#endif
@@ -536,6 +538,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
+18 -66
View File
@@ -16,7 +16,7 @@ name and promote with an atomic rename.
from __future__ import annotations
from collections.abc import Iterable, Iterator
from collections.abc import Iterator
from concurrent.futures import ThreadPoolExecutor
from contextlib import contextmanager, suppress
import hashlib
@@ -55,8 +55,8 @@ def _preserved_sys_path() -> Iterator[None]:
sys.path[:] = saved
# Cap for network-bound work: resolutions, HEAD probes, clone floor
_NETWORK_WORKERS = 8
# Concurrent registry resolutions / HEAD probes (each is network-bound)
_RESOLVE_WORKERS = 8
# A hung child must not block the build; downloads resume on the next run
_PREFETCH_TIMEOUT = 20 * 60
@@ -341,7 +341,7 @@ def _registry_jobs(
if not pending:
return [], 0, []
# Serial resolutions (registry GET + mirror HEAD each) dominate
with ThreadPoolExecutor(max_workers=min(_NETWORK_WORKERS, len(pending))) as ex:
with ThreadPoolExecutor(max_workers=min(_RESOLVE_WORKERS, len(pending))) as ex:
results = list(ex.map(_resolve, pending))
jobs: list[tuple[str, int, Any]] = []
installable: list[tuple[str, Any]] = []
@@ -374,47 +374,14 @@ def _registry_jobs(
return jobs, failed, installable
# The schemes pio's VCSClientFactory dispatches on (Git/Hg/SvnClient)
_VCS_URI_PREFIXES = ("git+", "hg+", "svn+", "git://", "hg://", "svn://")
def _is_vcs_spec_uri(url: str | None) -> bool:
"""Whether pio's ``install_from_uri`` would clone this URI (PackageSpec
normalizes git URLs to ``git+``). The .git check runs first so an
un-normalized repo URL fails as a clone, not as an archive download."""
if not url or url.startswith(("file://", "symlink://")):
return False
if url.split("#", 1)[0].endswith(".git"):
return True
if url.startswith(("http://", "https://")):
return False
return url.startswith(_VCS_URI_PREFIXES)
# (name, spec) from wave 1, (name, spec, compatibility) from dep waves
_Entry = tuple[str, Any] | tuple[str, Any, Any]
def _entry_is_vcs(entry: _Entry) -> bool:
"""Whether this pre-install entry is cloned rather than unpacked."""
return _is_vcs_spec_uri(entry[1].uri)
def _clones_first(entries: Iterable[_Entry]) -> list[_Entry]:
"""Clones first: they wait on the network, so they must not queue
behind CPU-bound archive extractions in the pre-install pool."""
return sorted(entries, key=lambda entry: not _entry_is_vcs(entry))
def _uri_jobs(
manager: Any, specs: list[Any], seen: set[str]
) -> tuple[list[tuple[str, int, Any]], int, list[tuple[str, Any]]]:
"""Jobs for direct-URL specs; a HEAD sizes each for the combined bar.
Also returns how many HEAD probes errored (an absent length is not an
error) and the ``(name, spec)`` pairs to pre-install: downloaded
archives, plus VCS specs, which have no archive -- the pre-install
itself clones them, in parallel instead of one at a time in pio run.
error) and the ``(name, spec)`` pairs whose archives will be
installable.
"""
from esphome.net_retry import fetch_with_retry, http_request
@@ -422,25 +389,13 @@ def _uri_jobs(
installable: list[tuple[str, Any]] = []
for spec in specs:
url = spec.uri
if not url:
continue
is_vcs = _is_vcs_spec_uri(url)
if not is_vcs and not url.startswith(("http://", "https://")):
if not url.startswith(("file://", "symlink://")):
_LOGGER.debug(
"Unrecognized package URI, leaving it to pio run: %s", url
)
continue # file/symlink specs are copied in place by pio run
if not url or not url.startswith(("http://", "https://")):
continue # git+/file specs are cloned/copied, not downloaded
if url.split("#", 1)[0].endswith(".git"):
continue # bare-URL VCS spec; PlatformIO clones it
if manager.get_package(spec):
continue
name = spec.name or url.rsplit("/", 1)[-1]
if is_vcs:
# The pre-install clones it, gated like the cached-archive
# branch below: only a custom name is the destination dir.
# Platform tool specs always parse as custom-named
if spec.has_custom_name():
installable.append((name, spec))
continue
# PlatformIO downloads URL specs with no checksum
dl_path = Path(manager.compute_download_path(url, ""))
if dl_path.is_file():
@@ -454,7 +409,7 @@ def _uri_jobs(
if str(dl_path) in seen:
continue # another spec already claimed this .part
seen.add(str(dl_path))
candidates.append((name, url, dl_path, spec))
candidates.append((spec.name, url, dl_path, spec))
errors: list[str] = []
@@ -480,7 +435,7 @@ def _uri_jobs(
if not candidates:
return [], 0, installable
with ThreadPoolExecutor(max_workers=min(_NETWORK_WORKERS, len(candidates))) as ex:
with ThreadPoolExecutor(max_workers=min(_RESOLVE_WORKERS, len(candidates))) as ex:
sizes = list(ex.map(_head_size, [url for _, url, _, _ in candidates]))
jobs: list[tuple[str, int, Any]] = []
failed = 0
@@ -628,6 +583,10 @@ def _uri_fetch_job(manager: Any, url: str, dl_path: Path, size: int) -> Any:
return run
# (name, spec) from wave 1, (name, spec, compatibility) from dep waves
_Entry = tuple[str, Any] | tuple[str, Any, Any]
def _dependency_entries(
manager: Any, entries: list[_Entry], seen_names: set[str]
) -> list[_Entry]:
@@ -742,14 +701,7 @@ def _preinstall(
would hang, not fail). Waves skip dependencies; the installed
manifests feed the next wave. Any failure falls back to pio run.
"""
entries = _clones_first(entries)
clones = sum(1 for entry in entries if _entry_is_vcs(entry))
# Network-bound clones run wide even on small-core runners; capped
# since each worker builds a sibling manager and may run a
# postinstall, and a mixed wave's extractions inherit the width
workers = min(
max(get_usable_cpu_count(), min(clones, _NETWORK_WORKERS)), len(entries)
)
workers = min(get_usable_cpu_count(), len(entries))
# One manager per worker (_install mutates instance state); built
# serially because construction rewires the shared manager logger
managers: SimpleQueue = SimpleQueue()
@@ -781,7 +733,7 @@ def _preinstall(
raise
_LOGGER.info(
"Installing %d PlatformIO package(s) with %d worker(s): %s",
"Installing %d PlatformIO package(s) with %d extraction worker(s): %s",
len(entries),
workers,
", ".join(name for name, *_ in entries),
@@ -0,0 +1,11 @@
# STA with AP fallback plus local: true opts the fallback into captive AP mode; exercises
# the runtime start (fallback branch in wifi loop) and end (on STA connect) paths.
wifi:
ssid: MySSID
password: password1
ap:
ssid: "ESPHome-Test"
password: "Test1234!"
web_server:
local: true
@@ -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
+141 -140
View File
@@ -1,142 +1,143 @@
{
"tests/integration/test_action_concurrent_reentry.py": 45.23,
"tests/integration/test_addressable_light_transition.py": 74.47,
"tests/integration/test_alarm_control_panel_state_transitions.py": 74.1,
"tests/integration/test_api_action_metadata.py": 62.1,
"tests/integration/test_api_action_responses.py": 71.08,
"tests/integration/test_api_action_timeout.py": 21.64,
"tests/integration/test_api_conditional_memory.py": 13.72,
"tests/integration/test_api_custom_services.py": 24.16,
"tests/integration/test_api_get_time_response_timezone.py": 23.48,
"tests/integration/test_api_homeassistant.py": 37.87,
"tests/integration/test_api_homeassistant_action_no_subscriber.py": 14.38,
"tests/integration/test_api_list_entities_backpressure.py": 26.85,
"tests/integration/test_api_message_size_batching.py": 33.36,
"tests/integration/test_api_reboot_timeout.py": 13.63,
"tests/integration/test_api_string_lambda.py": 25.04,
"tests/integration/test_api_vv_logging.py": 16.6,
"tests/integration/test_api_zero_psk_provisioning.py": 43.14,
"tests/integration/test_areas_and_devices.py": 25.98,
"tests/integration/test_automation_wait_actions.py": 21.91,
"tests/integration/test_automations.py": 42.43,
"tests/integration/test_batch_delay_zero_rapid_transitions.py": 16.65,
"tests/integration/test_binary_sensor_autorepeat_filter.py": 28.67,
"tests/integration/test_binary_sensor_invalidate_state.py": 23.69,
"tests/integration/test_blocking_warning_log_time_not_charged_to_next_operation.py": 22.99,
"tests/integration/test_build_info.py": 24.96,
"tests/integration/test_camera_mock.py": 14.47,
"tests/integration/test_climate_control_action.py": 31.07,
"tests/integration/test_climate_custom_modes.py": 28.59,
"tests/integration/test_continuation_actions.py": 14.96,
"tests/integration/test_cover_control_action.py": 26.14,
"tests/integration/test_crc8_helper.py": 10.92,
"tests/integration/test_device_id_in_state.py": 64.97,
"tests/integration/test_duplicate_entities.py": 30.81,
"tests/integration/test_entity_icon.py": 32.85,
"tests/integration/test_fan_turn_on_action.py": 24.91,
"tests/integration/test_fnv1_hash_object_id.py": 12.54,
"tests/integration/test_fnv1a_hash.py": 21.8,
"tests/integration/test_gpio_expander_cache.py": 5.2,
"tests/integration/test_host_logger_thread_safety.py": 21.7,
"tests/integration/test_host_mode_basic.py": 13.62,
"tests/integration/test_host_mode_batch_delay.py": 14.56,
"tests/integration/test_host_mode_climate_basic_state.py": 30.95,
"tests/integration/test_host_mode_climate_control.py": 29.06,
"tests/integration/test_host_mode_empty_string_options.py": 27.22,
"tests/integration/test_host_mode_entity_fields.py": 30.95,
"tests/integration/test_host_mode_fan_preset.py": 14.44,
"tests/integration/test_host_mode_many_entities.py": 54.13,
"tests/integration/test_host_mode_many_entities_multiple_connections.py": 32.17,
"tests/integration/test_host_mode_noise_encryption.py": 42.77,
"tests/integration/test_host_mode_reconnect.py": 4.06,
"tests/integration/test_host_mode_sensor.py": 13.47,
"tests/integration/test_host_ota.py": 21.4,
"tests/integration/test_host_preferences.py": 25.43,
"tests/integration/test_host_preferences_suspend_resume.py": 19.2,
"tests/integration/test_improv_serial_uart.py": 31.52,
"tests/integration/test_large_message_batching.py": 15.64,
"tests/integration/test_legacy_area.py": 22.63,
"tests/integration/test_legacy_climate_compat.py": 26.13,
"tests/integration/test_legacy_fan_compat.py": 24.05,
"tests/integration/test_light_automations.py": 30.86,
"tests/integration/test_light_binary_effect_off_phase.py": 23.19,
"tests/integration/test_light_calls.py": 32.35,
"tests/integration/test_light_constant_brightness.py": 29.89,
"tests/integration/test_light_control_action.py": 29.06,
"tests/integration/test_light_dim_relative_action.py": 29.61,
"tests/integration/test_light_effect_zero_brightness.py": 18.68,
"tests/integration/test_light_initial_state.py": 24.49,
"tests/integration/test_light_toggle_action.py": 26.46,
"tests/integration/test_lock_automations.py": 23.28,
"tests/integration/test_logger_buffered_recursion_guard.py": 24.29,
"tests/integration/test_loop_disable_enable.py": 45.28,
"tests/integration/test_loop_interval_decoupling.py": 28.35,
"tests/integration/test_loop_interval_default_not_pulled_forward.py": 21.97,
"tests/integration/test_micros_to_millis.py": 20.79,
"tests/integration/test_multi_click_trigger.py": 26.2,
"tests/integration/test_multi_device_preferences.py": 16.87,
"tests/integration/test_noise_encryption_key_protection.py": 77.05,
"tests/integration/test_object_id_api_verification.py": 73.51,
"tests/integration/test_object_id_friendly_name_no_mac_suffix.py": 62.33,
"tests/integration/test_object_id_no_friendly_name.py": 43.47,
"tests/integration/test_online_image_auto_detects_image_bmp_mime.py": 32.21,
"tests/integration/test_online_image_auto_detects_redirected_image_bmp_mime.py": 56.86,
"tests/integration/test_online_image_bmp.py": 50.9,
"tests/integration/test_oversized_payloads.py": 53.2,
"tests/integration/test_preference_key_stability.py": 26.09,
"tests/integration/test_runtime_stats.py": 18.34,
"tests/integration/test_safe_mode_loop_runs.py": 10.07,
"tests/integration/test_scheduler_blocking_warning.py": 40.91,
"tests/integration/test_scheduler_bulk_cleanup.py": 23.14,
"tests/integration/test_scheduler_defer_cancel.py": 24.54,
"tests/integration/test_scheduler_defer_cancel_regular.py": 13.48,
"tests/integration/test_scheduler_defer_fifo_simple.py": 26.86,
"tests/integration/test_scheduler_defer_stress.py": 27.23,
"tests/integration/test_scheduler_heap_stress.py": 24.02,
"tests/integration/test_scheduler_internal_id_no_collision.py": 24.57,
"tests/integration/test_scheduler_interval_reschedule.py": 13.12,
"tests/integration/test_scheduler_interval_zero_coerced.py": 22.91,
"tests/integration/test_scheduler_null_name.py": 23.46,
"tests/integration/test_scheduler_numeric_id_test.py": 24.54,
"tests/integration/test_scheduler_pool.py": 25.0,
"tests/integration/test_scheduler_rapid_cancellation.py": 14.68,
"tests/integration/test_scheduler_recursive_timeout.py": 25.35,
"tests/integration/test_scheduler_removed_item_race.py": 26.19,
"tests/integration/test_scheduler_self_keyed.py": 23.43,
"tests/integration/test_scheduler_simultaneous_callbacks.py": 22.16,
"tests/integration/test_scheduler_string_test.py": 15.22,
"tests/integration/test_script_array_params.py": 14.67,
"tests/integration/test_script_delay_params.py": 15.65,
"tests/integration/test_script_queued.py": 24.93,
"tests/integration/test_script_queued_idle_loop.py": 5.04,
"tests/integration/test_script_wait_on_boot.py": 13.08,
"tests/integration/test_select_stringref_trigger.py": 29.6,
"tests/integration/test_sensor_filters_delta.py": 28.01,
"tests/integration/test_sensor_filters_ring_buffer.py": 25.04,
"tests/integration/test_sensor_filters_sliding_window.py": 71.5,
"tests/integration/test_sensor_filters_value_list.py": 16.94,
"tests/integration/test_sensor_timeout_filter.py": 29.48,
"tests/integration/test_socket_wake_gate_tcp.py": 20.36,
"tests/integration/test_status_flags.py": 37.42,
"tests/integration/test_strftime_to.py": 22.61,
"tests/integration/test_syslog.py": 16.34,
"tests/integration/test_template_alarm_control_panel_many_sensors.py": 29.81,
"tests/integration/test_template_text_save.py": 25.43,
"tests/integration/test_text_command.py": 23.34,
"tests/integration/test_text_sensor_raw_state.py": 69.57,
"tests/integration/test_uart_mock_ld2410.py": 37.95,
"tests/integration/test_uart_mock_ld2412.py": 93.22,
"tests/integration/test_uart_mock_ld2420.py": 43.24,
"tests/integration/test_uart_mock_ld2450.py": 31.75,
"tests/integration/test_uart_mock_modbus.py": 667.4,
"tests/integration/test_udp.py": 9.38,
"tests/integration/test_use_address_runtime.py": 37.05,
"tests/integration/test_valve_control_action.py": 24.47,
"tests/integration/test_varint_five_byte_device_id.py": 25.03,
"tests/integration/test_wait_until_mid_loop_timing.py": 23.73,
"tests/integration/test_wait_until_on_boot.py": 9.16,
"tests/integration/test_wait_until_ordering.py": 13.3,
"tests/integration/test_wait_until_reentrant_restart.py": 25.23,
"tests/integration/test_wake_loop_forces_phase_b.py": 23.34,
"tests/integration/test_water_heater_template.py": 17.67
"tests/integration/test_action_concurrent_reentry.py": 57.91,
"tests/integration/test_addressable_light_transition.py": 21.25,
"tests/integration/test_alarm_control_panel_state_transitions.py": 70.71,
"tests/integration/test_api_action_metadata.py": 66.6,
"tests/integration/test_api_action_responses.py": 36.1,
"tests/integration/test_api_action_timeout.py": 68.86,
"tests/integration/test_api_conditional_memory.py": 15.48,
"tests/integration/test_api_custom_services.py": 18.77,
"tests/integration/test_api_get_time_response_timezone.py": 21.08,
"tests/integration/test_api_homeassistant.py": 65.59,
"tests/integration/test_api_homeassistant_action_no_subscriber.py": 18.44,
"tests/integration/test_api_homeassistant_binary_sensor_initial_state.py": 15.05,
"tests/integration/test_api_list_entities_backpressure.py": 13.88,
"tests/integration/test_api_message_size_batching.py": 29.98,
"tests/integration/test_api_reboot_timeout.py": 16.05,
"tests/integration/test_api_string_lambda.py": 15.31,
"tests/integration/test_api_vv_logging.py": 19.28,
"tests/integration/test_api_zero_psk_provisioning.py": 31.5,
"tests/integration/test_areas_and_devices.py": 24.95,
"tests/integration/test_automation_wait_actions.py": 20.92,
"tests/integration/test_automations.py": 35.19,
"tests/integration/test_batch_delay_zero_rapid_transitions.py": 17.99,
"tests/integration/test_binary_sensor_autorepeat_filter.py": 20.39,
"tests/integration/test_binary_sensor_invalidate_state.py": 18.41,
"tests/integration/test_blocking_warning_log_time_not_charged_to_next_operation.py": 24.69,
"tests/integration/test_build_info.py": 18.7,
"tests/integration/test_camera_mock.py": 16.23,
"tests/integration/test_climate_control_action.py": 21.14,
"tests/integration/test_climate_custom_modes.py": 20.74,
"tests/integration/test_continuation_actions.py": 16.81,
"tests/integration/test_cover_control_action.py": 20.34,
"tests/integration/test_crc8_helper.py": 9.36,
"tests/integration/test_device_id_in_state.py": 44.67,
"tests/integration/test_duplicate_entities.py": 23.58,
"tests/integration/test_entity_icon.py": 34.35,
"tests/integration/test_fan_turn_on_action.py": 24.23,
"tests/integration/test_fnv1_hash_object_id.py": 16.21,
"tests/integration/test_fnv1a_hash.py": 13.38,
"tests/integration/test_gpio_expander_cache.py": 13.06,
"tests/integration/test_host_logger_thread_safety.py": 23.66,
"tests/integration/test_host_mode_basic.py": 8.01,
"tests/integration/test_host_mode_batch_delay.py": 21.0,
"tests/integration/test_host_mode_climate_basic_state.py": 22.14,
"tests/integration/test_host_mode_climate_control.py": 19.39,
"tests/integration/test_host_mode_empty_string_options.py": 21.76,
"tests/integration/test_host_mode_entity_fields.py": 29.61,
"tests/integration/test_host_mode_fan_preset.py": 20.01,
"tests/integration/test_host_mode_many_entities.py": 39.08,
"tests/integration/test_host_mode_many_entities_multiple_connections.py": 23.92,
"tests/integration/test_host_mode_noise_encryption.py": 42.42,
"tests/integration/test_host_mode_reconnect.py": 3.41,
"tests/integration/test_host_mode_sensor.py": 22.96,
"tests/integration/test_host_ota.py": 29.5,
"tests/integration/test_host_preferences.py": 16.06,
"tests/integration/test_host_preferences_suspend_resume.py": 18.71,
"tests/integration/test_improv_serial_uart.py": 20.22,
"tests/integration/test_large_message_batching.py": 26.56,
"tests/integration/test_legacy_area.py": 22.72,
"tests/integration/test_legacy_climate_compat.py": 14.13,
"tests/integration/test_legacy_fan_compat.py": 14.33,
"tests/integration/test_light_automations.py": 18.81,
"tests/integration/test_light_binary_effect_off_phase.py": 8.38,
"tests/integration/test_light_calls.py": 21.88,
"tests/integration/test_light_constant_brightness.py": 59.45,
"tests/integration/test_light_control_action.py": 31.91,
"tests/integration/test_light_dim_relative_action.py": 14.43,
"tests/integration/test_light_effect_zero_brightness.py": 25.05,
"tests/integration/test_light_initial_state.py": 18.97,
"tests/integration/test_light_toggle_action.py": 17.44,
"tests/integration/test_lock_automations.py": 18.9,
"tests/integration/test_logger_buffered_recursion_guard.py": 18.2,
"tests/integration/test_loop_disable_enable.py": 63.35,
"tests/integration/test_loop_interval_decoupling.py": 17.7,
"tests/integration/test_loop_interval_default_not_pulled_forward.py": 21.56,
"tests/integration/test_micros_to_millis.py": 15.89,
"tests/integration/test_multi_click_trigger.py": 17.23,
"tests/integration/test_multi_device_preferences.py": 19.4,
"tests/integration/test_noise_encryption_key_protection.py": 72.59,
"tests/integration/test_object_id_api_verification.py": 19.22,
"tests/integration/test_object_id_friendly_name_no_mac_suffix.py": 16.77,
"tests/integration/test_object_id_no_friendly_name.py": 45.8,
"tests/integration/test_online_image_auto_detects_image_bmp_mime.py": 86.73,
"tests/integration/test_online_image_auto_detects_redirected_image_bmp_mime.py": 40.4,
"tests/integration/test_online_image_bmp.py": 37.24,
"tests/integration/test_oversized_payloads.py": 55.75,
"tests/integration/test_preference_key_stability.py": 25.49,
"tests/integration/test_runtime_stats.py": 29.81,
"tests/integration/test_safe_mode_loop_runs.py": 6.26,
"tests/integration/test_scheduler_blocking_warning.py": 37.98,
"tests/integration/test_scheduler_bulk_cleanup.py": 18.67,
"tests/integration/test_scheduler_defer_cancel.py": 18.46,
"tests/integration/test_scheduler_defer_cancel_regular.py": 16.34,
"tests/integration/test_scheduler_defer_fifo_simple.py": 18.26,
"tests/integration/test_scheduler_defer_stress.py": 17.74,
"tests/integration/test_scheduler_heap_stress.py": 3.89,
"tests/integration/test_scheduler_internal_id_no_collision.py": 20.01,
"tests/integration/test_scheduler_interval_reschedule.py": 16.29,
"tests/integration/test_scheduler_interval_zero_coerced.py": 16.09,
"tests/integration/test_scheduler_null_name.py": 14.69,
"tests/integration/test_scheduler_numeric_id_test.py": 17.08,
"tests/integration/test_scheduler_pool.py": 19.88,
"tests/integration/test_scheduler_rapid_cancellation.py": 4.42,
"tests/integration/test_scheduler_recursive_timeout.py": 4.3,
"tests/integration/test_scheduler_removed_item_race.py": 15.49,
"tests/integration/test_scheduler_self_keyed.py": 25.77,
"tests/integration/test_scheduler_simultaneous_callbacks.py": 14.84,
"tests/integration/test_scheduler_string_test.py": 15.42,
"tests/integration/test_script_array_params.py": 12.73,
"tests/integration/test_script_delay_params.py": 12.69,
"tests/integration/test_script_queued.py": 20.38,
"tests/integration/test_script_queued_idle_loop.py": 25.06,
"tests/integration/test_script_wait_on_boot.py": 15.67,
"tests/integration/test_select_stringref_trigger.py": 19.48,
"tests/integration/test_sensor_filters_delta.py": 27.62,
"tests/integration/test_sensor_filters_ring_buffer.py": 20.27,
"tests/integration/test_sensor_filters_sliding_window.py": 56.28,
"tests/integration/test_sensor_filters_value_list.py": 20.6,
"tests/integration/test_sensor_timeout_filter.py": 22.21,
"tests/integration/test_socket_wake_gate_tcp.py": 16.37,
"tests/integration/test_status_flags.py": 29.68,
"tests/integration/test_strftime_to.py": 17.42,
"tests/integration/test_syslog.py": 18.39,
"tests/integration/test_template_alarm_control_panel_many_sensors.py": 25.61,
"tests/integration/test_template_text_save.py": 19.16,
"tests/integration/test_text_command.py": 16.43,
"tests/integration/test_text_sensor_raw_state.py": 17.19,
"tests/integration/test_uart_mock_ld2410.py": 37.0,
"tests/integration/test_uart_mock_ld2412.py": 40.82,
"tests/integration/test_uart_mock_ld2420.py": 32.7,
"tests/integration/test_uart_mock_ld2450.py": 32.84,
"tests/integration/test_uart_mock_modbus.py": 548.87,
"tests/integration/test_udp.py": 16.67,
"tests/integration/test_use_address_runtime.py": 27.26,
"tests/integration/test_valve_control_action.py": 24.58,
"tests/integration/test_varint_five_byte_device_id.py": 22.5,
"tests/integration/test_wait_until_mid_loop_timing.py": 22.05,
"tests/integration/test_wait_until_on_boot.py": 10.37,
"tests/integration/test_wait_until_ordering.py": 18.23,
"tests/integration/test_wait_until_reentrant_restart.py": 19.35,
"tests/integration/test_wake_loop_forces_phase_b.py": 17.83,
"tests/integration/test_water_heater_template.py": 25.7
}
@@ -0,0 +1,150 @@
"""Tests for the web_server AP mode helpers."""
import logging
import pytest
from esphome.components.web_server import (
_final_validate_ap_mode,
serve_captive,
serve_local,
)
from esphome.const import (
CONF_AP,
CONF_LOCAL,
CONF_NETWORKS,
CONF_PORT,
CONF_SSID,
CONF_VERSION,
CONF_WIFI,
)
import esphome.final_validate as fv
AP_ONLY = {CONF_AP: {}}
AP_FALLBACK = {CONF_AP: {}, CONF_NETWORKS: [{CONF_SSID: "x"}]}
STA_ONLY = {CONF_NETWORKS: [{CONF_SSID: "x"}]}
@pytest.mark.parametrize(
("web_server_config", "wifi_config", "expected"),
[
# AP only: embed the interface, the AP has no internet.
({CONF_VERSION: 2}, AP_ONLY, True),
({CONF_VERSION: 3}, AP_ONLY, True),
# Explicit setting always wins.
({CONF_VERSION: 2, CONF_LOCAL: False}, AP_ONLY, False),
({CONF_VERSION: 2, CONF_LOCAL: True}, STA_ONLY, True),
# AP fallback, no AP, no wifi, or version 1 (no local mode): hosted page.
({CONF_VERSION: 2}, AP_FALLBACK, False),
({CONF_VERSION: 2}, STA_ONLY, False),
({CONF_VERSION: 2}, None, False),
({CONF_VERSION: 1}, AP_ONLY, False),
],
)
def test_serve_local(
web_server_config: dict, wifi_config: dict | None, expected: bool
) -> None:
"""The interface is embedded for AP only WiFi unless local is set explicitly."""
assert serve_local(web_server_config, wifi_config) is expected
@pytest.mark.parametrize(
("web_server_config", "full_config", "expected"),
[
# AP only: local is implied, web_server is the captive portal.
({CONF_VERSION: 2}, {CONF_WIFI: AP_ONLY}, True),
# Captive portal probes only work on port 80.
({CONF_VERSION: 2, CONF_PORT: 8080}, {CONF_WIFI: AP_ONLY}, False),
# AP fallback needs an explicit local: true to be captive.
({CONF_VERSION: 2}, {CONF_WIFI: AP_FALLBACK}, False),
({CONF_VERSION: 2, CONF_LOCAL: True}, {CONF_WIFI: AP_FALLBACK}, True),
# captive_portal owns the role when configured.
({CONF_VERSION: 2}, {CONF_WIFI: AP_ONLY, "captive_portal": {}}, False),
# No AP, no wifi, hosted page, or version 1: never captive.
({CONF_VERSION: 2, CONF_LOCAL: True}, {CONF_WIFI: STA_ONLY}, False),
({CONF_VERSION: 2, CONF_LOCAL: True}, {}, False),
({CONF_VERSION: 2, CONF_LOCAL: False}, {CONF_WIFI: AP_ONLY}, False),
({CONF_VERSION: 1}, {CONF_WIFI: AP_ONLY}, False),
],
)
def test_serve_captive(
web_server_config: dict, full_config: dict, expected: bool
) -> None:
web_server_config.setdefault(CONF_PORT, 80)
assert serve_captive(web_server_config, full_config) is expected
@pytest.mark.parametrize(
("web_server_config", "expect_warning"),
[
# Explicit local: false on an AP only device: the hosted page will stay blank.
({CONF_VERSION: 2, CONF_PORT: 80, CONF_LOCAL: False}, True),
# Default: embedded and captive, nothing to warn about.
({CONF_VERSION: 2, CONF_PORT: 80}, False),
],
)
def test_final_validate_ap_mode_warns_for_hosted_page(
web_server_config: dict, expect_warning: bool, caplog: pytest.LogCaptureFixture
) -> None:
token = fv.full_config.set({"web_server": web_server_config, CONF_WIFI: AP_ONLY})
try:
with caplog.at_level(logging.WARNING):
_final_validate_ap_mode(web_server_config)
finally:
fv.full_config.reset(token)
assert ("stays blank" in caplog.text) is expect_warning
def test_final_validate_ap_mode_warns_for_non_default_port(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Captive portal detection needs port 80; other ports get a hint, not captive mode."""
config = {CONF_VERSION: 2, CONF_PORT: 8080}
token = fv.full_config.set({"web_server": config, CONF_WIFI: AP_ONLY})
try:
with caplog.at_level(logging.WARNING):
_final_validate_ap_mode(config)
finally:
fv.full_config.reset(token)
assert "cannot open automatically" in caplog.text
assert "http://192.168.4.1:8080/" in caplog.text
def test_final_validate_ap_mode_port_warning_uses_manual_ip(
caplog: pytest.LogCaptureFixture,
) -> None:
"""The manual URL in the port warning honors wifi.ap.manual_ip."""
from esphome.const import CONF_MANUAL_IP, CONF_STATIC_IP
config = {CONF_VERSION: 2, CONF_PORT: 8080}
wifi = {CONF_AP: {CONF_MANUAL_IP: {CONF_STATIC_IP: "10.0.0.1"}}}
token = fv.full_config.set({"web_server": config, CONF_WIFI: wifi})
try:
with caplog.at_level(logging.WARNING):
_final_validate_ap_mode(config)
finally:
fv.full_config.reset(token)
assert "http://10.0.0.1:8080/" in caplog.text
@pytest.mark.parametrize(
("wifi_config", "expected"),
[
# Explicit local: true on a fallback AP: announce the captive fallback role.
(AP_FALLBACK, "fallback access point"),
# Explicit local: true on AP only skips the implied-local info; still announce.
(AP_ONLY, "captive portal while the access point"),
],
)
def test_final_validate_ap_mode_informs_explicit_local_captive(
wifi_config: dict, expected: str, caplog: pytest.LogCaptureFixture
) -> None:
"""Explicit local: true logs that web_server becomes the captive portal."""
config = {CONF_VERSION: 2, CONF_PORT: 80, CONF_LOCAL: True}
token = fv.full_config.set({"web_server": config, CONF_WIFI: wifi_config})
try:
with caplog.at_level(logging.INFO):
_final_validate_ap_mode(config)
finally:
fv.full_config.reset(token)
assert expected in caplog.text
+18 -125
View File
@@ -588,8 +588,7 @@ def test_registry_jobs_one_bad_spec_keeps_the_rest(tmp_path: Path) -> None:
def test_uri_jobs_head_sizes_the_bar(tmp_path: Path) -> None:
"""HEAD sizes direct-URL specs; VCS specs skip the download but are
still installable (the pre-install clones them in parallel)."""
"""HEAD sizes direct-URL specs; git and unreachable URLs are skipped."""
m = _fake_manager(tmp_path)
resp = MagicMock()
resp.headers = {"content-length": "2222"}
@@ -598,14 +597,15 @@ def test_uri_jobs_head_sizes_the_bar(tmp_path: Path) -> None:
m,
[
_FakeSpec(uri="https://x/big.zip", name="big", custom_name=True),
_FakeSpec(uri="git+https://x/repo.git", name="repo", custom_name=True),
_FakeSpec(uri="git+https://x/repo.git", name="repo"),
_FakeSpec(uri="https://x/repo.git#v1", name="barevcs"),
_FakeSpec(name="registry"),
],
set(),
)
assert failed == 0
assert [(n, s) for n, s, _ in jobs] == [("big", 2222)]
assert [n for n, _ in installable] == ["repo", "big"]
assert [n for n, _ in installable] == ["big"]
# a successful HEAD with no Content-Length is a clean skip
resp.headers = {}
with patch("esphome.net_retry.http_request", return_value=resp):
@@ -614,67 +614,6 @@ def test_uri_jobs_head_sizes_the_bar(tmp_path: Path) -> None:
) == ([], 0, [])
def test_uri_jobs_vcs_specs_installable_without_probe(tmp_path: Path) -> None:
"""VCS specs never probe the network; custom-named uninstalled ones
pre-install, everything else is left to pio run."""
m = _fake_manager(tmp_path)
with patch("esphome.net_retry.http_request") as mock_head:
jobs, failed, installable = pf._uri_jobs(
m,
[
_FakeSpec(
uri="git+https://x/tool.git#1.0", name="tool", custom_name=True
),
_FakeSpec(uri="hg+https://x/old", name="mercurial", custom_name=True),
_FakeSpec(uri="git+https://x/derived.git", name="derived"),
# An un-normalized repo URL classifies as VCS (never as a
# downloadable archive), then drops here as derived-name
_FakeSpec(uri="https://x/unnorm.git", name="unnorm"),
_FakeSpec(uri="file:///local/dir", name="local"),
# A local .git path is copied in place, never cloned
_FakeSpec(uri="file:///local/repo.git", name="localgit"),
_FakeSpec(uri="symlink:///local/dir", name="link"),
],
set(),
)
mock_head.assert_not_called()
assert (jobs, failed) == ([], 0)
assert [n for n, _ in installable] == ["tool", "mercurial"]
# Positive classification: an unknown scheme is left to pio run
with patch("esphome.net_retry.http_request") as mock_head:
assert pf._uri_jobs(
m, [_FakeSpec(uri="weird://x/pkg", name="weird")], set()
) == ([], 0, [])
mock_head.assert_not_called()
m.get_package.return_value = object() # already installed: warm and silent
with patch("esphome.net_retry.http_request"):
assert pf._uri_jobs(
m,
[
_FakeSpec(
uri="git+https://x/tool.git#1.0", name="tool", custom_name=True
)
],
set(),
) == ([], 0, [])
def test_clones_first_orders_vcs_before_archives() -> None:
"""The pre-install pool receives clones first: they wait on the
network and must not queue behind CPU-bound archive extractions."""
archive = ("zip", _FakeSpec(uri="https://x/a.zip", name="zip"))
registry = ("reg", _FakeSpec(uri=None, name="reg"))
clone = ("repo", _FakeSpec(uri="git+https://x/repo.git", name="repo"))
ordered = pf._clones_first([archive, registry, clone])
assert ordered[0] == clone
# Stable partition: non-clone relative order is preserved
assert ordered[1:] == [archive, registry]
assert pf._entry_is_vcs(clone)
assert not pf._entry_is_vcs(archive)
assert not pf._entry_is_vcs(registry)
def test_uri_jobs_head_failure_counts_as_unresolved(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
@@ -1661,9 +1600,12 @@ def test_preinstall_dependency_wave_skips_seen_names(tmp_path: Path) -> None:
assert installed == ["noise-c"]
def _wave_manager(tmp_path, on_install):
"""A minimal pio-manager stand-in for _preinstall pool tests;
``on_install(manager, spec)`` observes each _install call."""
def test_preinstall_uses_distinct_managers_in_parallel(tmp_path: Path) -> None:
"""Each worker thread gets its own pre-built manager and installs
genuinely overlap (the barrier deadlocks a serial pool). The worker
count is pinned so a 1-CPU host cannot serialize the pool."""
barrier = threading.Barrier(2, timeout=5)
used: set = set()
class _WaveManager:
package_dir = str(tmp_path)
@@ -1694,59 +1636,20 @@ def _wave_manager(tmp_path, on_install):
return None
def _install(self, spec, skip_dependencies, compatibility=None) -> None:
on_install(self, spec)
used.add(id(self))
barrier.wait()
return _WaveManager
@pytest.mark.parametrize(
("cpu_count", "entries"),
[
(2, [("a@1", _FakeSpec(name="a")), ("b@1", _FakeSpec(name="b"))]),
# The clone floor: network-bound clones run wide on a 1-CPU host
(
1,
[
(f"r{i}", _FakeSpec(uri=f"git+https://x/r{i}.git", name=f"r{i}"))
for i in range(4)
],
),
],
ids=("cpu-sized", "clone-floor"),
)
def test_preinstall_pool_width(tmp_path: Path, cpu_count: int, entries: list) -> None:
"""The barrier deadlocks unless every entry gets its own manager
and runs concurrently."""
barrier = threading.Barrier(len(entries), timeout=5)
used: set = set()
def on_install(mgr, spec) -> None:
used.add(id(mgr))
barrier.wait()
cls = _wave_manager(tmp_path, on_install)
seed = cls(str(tmp_path))
with patch.object(pf, "get_usable_cpu_count", return_value=cpu_count):
pf._preinstall(seed, entries)
assert len(used) == len(entries)
assert id(seed) not in used
def test_preinstall_orders_clones_before_extractions(tmp_path: Path) -> None:
"""With one worker, the clone installs before the archive
regardless of caller order."""
order: list[str] = []
cls = _wave_manager(tmp_path, lambda mgr, spec: order.append(spec.name))
seed = cls(str(tmp_path))
with patch.object(pf, "get_usable_cpu_count", return_value=1):
seed = _WaveManager(str(tmp_path))
with patch.object(pf, "get_usable_cpu_count", return_value=2):
pf._preinstall(
seed,
[
("zip", _FakeSpec(uri="https://x/a.zip", name="zip")),
("repo", _FakeSpec(uri="git+https://x/repo.git", name="repo")),
("a@1", _FakeSpec(name="a")),
("b@1", _FakeSpec(name="b")),
],
)
assert order == ["repo", "zip"]
assert len(used) == 2
assert id(seed) not in used
def test_sibling_manager_and_sigterm() -> None:
@@ -1900,13 +1803,3 @@ def test_platformio_private_api_contract() -> None:
derived = PackageSpec("https://x/y/archive/master.zip")
assert derived.name and not derived.has_custom_name()
assert PackageSpec("Foo=https://x/y/archive/master.zip").has_custom_name()
# _is_vcs_spec_uri relies on bare .git URLs normalizing to git+, on
# both parse paths (raw string, and requirements= for platform tools)
assert PackageSpec("https://github.com/x/y.git#v1").uri.startswith("git+")
platform_tool = PackageSpec(
owner="o", name="tool-x", requirements="https://github.com/x/y.git"
)
assert platform_tool.uri.startswith("git+")
# A URL requirement re-parses as name=url, marking the name custom;
# this is what keeps platform tool clones in the parallel pre-install
assert platform_tool.has_custom_name()