Compare commits

..
Author SHA1 Message Date
J. Nick Koston 421c5e5d32 Merge branch 'dev' into platformio-prefetch-git-clones 2026-09-02 05:16:40 -04:00
J. Nick Koston ab59822c1f Tighten comments 2026-08-31 17:41:13 -04:00
J. Nick Koston fa5180bd51 Keep local .git paths with pio run 2026-08-31 16:49:39 -04:00
J. Nick Koston 8582bf194a Classify .git URLs before the scheme exclusion, restore the name fallback 2026-08-31 16:31:28 -04:00
J. Nick Koston 328e83ad64 Simplify: one custom-name rule in _uri_jobs, ordering owned by the pool 2026-08-31 16:08:57 -04:00
J. Nick Koston 6877513195 Classify VCS URIs positively and pin the clone floor 2026-08-31 15:39:01 -04:00
J. Nick Koston 03104b894b Gate derived-name lib clones out of the pool, widen it for clones, pin the wiring 2026-08-31 14:19:16 -05:00
J. Nick Koston 26a308a82d Address review: name the clones-first ordering, harden the name fallback 2026-08-31 13:54:18 -05:00
J. Nick Koston 938f598709 Merge branch 'dev' into platformio-prefetch-git-clones
# Conflicts:
#	esphome/platformio/prefetch.py
2026-08-31 13:23:59 -05:00
J. Nick Koston 2b4a196cf8 Merge branch 'dev' into platformio-prefetch-git-clones 2026-08-28 14:26:22 -05:00
J. Nick Koston 3f65f5c12c Use the computed name for download candidates too 2026-08-27 15:26:29 -05:00
J. Nick Koston 7cc892ea14 Pin PackageSpec git URL normalization in the contract test 2026-08-27 15:23:11 -05:00
J. Nick Koston 9e4bec7e49 [core] Clone git PlatformIO packages in parallel in the prefetch 2026-08-27 15:09:03 -05:00
26 changed files with 435 additions and 822 deletions
+2 -96
View File
@@ -44,16 +44,6 @@ 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`.
@@ -152,47 +142,6 @@ file does, and it is the authority when they disagree. The most useful starting
* **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.
@@ -613,33 +562,6 @@ file does, and it is the authority when they disagree. The most useful starting
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)
@@ -677,25 +599,9 @@ file does, and it is the authority when they disagree. The most useful starting
```
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. **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_;
```
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.
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:
6. **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)
+26 -2
View File
@@ -3,6 +3,7 @@ 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,
@@ -14,6 +15,7 @@ from esphome.const import (
PLATFORM_LN882X,
PLATFORM_RP2,
PLATFORM_RTL87XX,
PlatformFramework,
)
from esphome.core import CORE, coroutine_with_priority
from esphome.coroutine import CoroPriority
@@ -74,7 +76,17 @@ def _final_validate(config: ConfigType) -> None:
"Add 'ap:' to your WiFi configuration to enable the captive portal."
)
web_server_base.consume_captive_dns_sockets(config, "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)
FINAL_VALIDATE_SCHEMA = _final_validate
@@ -94,4 +106,16 @@ async def to_code(config: ConfigType) -> None:
if config[CONF_COMPRESSION] == "gzip":
cg.add_define("USE_CAPTIVE_PORTAL_GZIP")
web_server_base.add_captive_dns_library()
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,
},
}
)
@@ -102,7 +102,17 @@ void CaptivePortal::start() {
this->base_->add_handler_without_auth(this);
}
this->dns_.start(wifi::global_wifi_component->wifi_soft_ap_ip());
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->initialized_ = true;
this->active_ = true;
@@ -1,11 +1,16 @@
#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 {
@@ -14,7 +19,17 @@ class CaptivePortal final : public AsyncWebHandler, public Component {
CaptivePortal(web_server_base::WebServerBase *base);
void setup() override;
void dump_config() override;
void loop() override { this->dns_.loop(); }
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
}
float get_setup_priority() const override;
void start();
bool is_active() const { return this->active_; }
@@ -22,7 +37,10 @@ class CaptivePortal final : public AsyncWebHandler, public Component {
this->active_ = false;
this->disable_loop(); // Stop processing DNS requests
this->base_->deinit();
this->dns_.stop();
if (this->dns_server_ != nullptr) {
this->dns_server_->stop();
this->dns_server_ = nullptr;
}
}
bool canHandle(AsyncWebServerRequest *request) const override {
@@ -42,7 +60,9 @@ class CaptivePortal final : public AsyncWebHandler, public Component {
web_server_base::WebServerBase *base_;
bool initialized_{false};
bool active_{false};
web_server_base::CaptiveDNS dns_;
#if defined(USE_ARDUINO) || defined(USE_ESP32)
std::unique_ptr<DNSServer> dns_server_{nullptr};
#endif
};
extern CaptivePortal *global_captive_portal; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
@@ -1,5 +1,5 @@
#include "dns_server_esp32_idf.h"
#if defined(USE_ESP32) && (defined(USE_CAPTIVE_PORTAL) || defined(USE_WEBSERVER_CAPTIVE))
#ifdef USE_ESP32
#include "esphome/core/log.h"
#include "esphome/core/hal.h"
@@ -7,9 +7,9 @@
#include <lwip/sockets.h>
#include <lwip/inet.h>
namespace esphome::web_server_base {
namespace esphome::captive_portal {
static const char *const TAG = "web_server_base.dns";
static const char *const TAG = "captive_portal.dns";
// DNS constants
static constexpr uint16_t DNS_PORT = 53;
@@ -202,6 +202,6 @@ void DNSServer::process_next_request() {
}
}
} // namespace esphome::web_server_base
} // namespace esphome::captive_portal
#endif // USE_ESP32 && (USE_CAPTIVE_PORTAL || USE_WEBSERVER_CAPTIVE)
#endif // USE_ESP32
@@ -1,15 +1,11 @@
#pragma once
#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))
#ifdef USE_ESP32
#include "esphome/core/helpers.h"
#include "esphome/components/network/ip_address.h"
#include "esphome/components/socket/socket.h"
namespace esphome::web_server_base {
namespace esphome::captive_portal {
class DNSServer {
public:
@@ -31,6 +27,6 @@ class DNSServer {
uint8_t buffer_[DNS_BUFFER_SIZE];
};
} // namespace esphome::web_server_base
} // namespace esphome::captive_portal
#endif // USE_ESP32 && (USE_CAPTIVE_PORTAL || USE_WEBSERVER_CAPTIVE)
#endif // USE_ESP32
+5 -116
View File
@@ -12,7 +12,6 @@ from esphome.components.logger import request_log_listener
from esphome.components.web_server_base import CONF_WEB_SERVER_BASE_ID
import esphome.config_validation as cv
from esphome.const import (
CONF_AP,
CONF_AUTH,
CONF_COMPRESSION,
CONF_CSS_INCLUDE,
@@ -24,19 +23,15 @@ 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,
@@ -51,23 +46,7 @@ from esphome.types import ConfigType
_LOGGER = logging.getLogger(__name__)
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
AUTO_LOAD = ["json", "web_server_base"]
AUTH_TYPE_BASIC = "basic"
AUTH_TYPE_DIGEST = "digest"
@@ -226,6 +205,9 @@ 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
@@ -352,95 +334,6 @@ 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)
@@ -541,12 +434,8 @@ 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 serve_local(config, CORE.config.get(CONF_WIFI)):
if CONF_LOCAL in config and config[CONF_LOCAL]:
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")
+3 -56
View File
@@ -44,10 +44,6 @@
#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"
@@ -338,15 +334,7 @@ void DeferredUpdateEventSourceList::on_client_disconnect_(DeferredUpdateEventSou
}
#endif
#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
}
WebServer::WebServer(web_server_base::WebServerBase *base) : base_(base) {}
#ifdef USE_WEBSERVER_CSS_INCLUDE
void WebServer::set_css_include(const char *css_include) { this->css_include_ = css_include; }
@@ -392,11 +380,6 @@ 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
@@ -412,52 +395,16 @@ void WebServer::setup() {
});
}
void WebServer::loop() {
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
// No SSE clients connected; stop looping until a new client connects via
// enable_loop_soon_any_context(). This is safe because:
// - set_interval/set_timeout/defer run via the Scheduler, independent of loop()
// - deferrable_send_state early-outs when no clients are connected
// - try_send_nodefer (log, ping) iterates sessions which are empty
// - REST API handlers use defer() which runs via the Scheduler
if (!keep_looping)
if (!this->events_.loop())
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,9 +4,6 @@
#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"
@@ -279,18 +276,6 @@ 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();
@@ -612,10 +597,6 @@ 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};
@@ -715,9 +696,5 @@ 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
+1 -30
View File
@@ -1,9 +1,8 @@
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, PlatformFramework
from esphome.const import CONF_ID
from esphome.core import CORE, coroutine_with_priority
from esphome.coroutine import CoroPriority
from esphome.helpers import copy_file_if_changed
@@ -27,22 +26,6 @@ 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.
@@ -98,15 +81,3 @@ 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,
},
}
)
@@ -1,58 +0,0 @@
#pragma once
#include "esphome/core/defines.h"
// DNS server that answers every name with the access point address, so a phone joining the
// AP runs its captive portal check against the device. Shared by captive_portal and the
// web_server AP mode; hides the ESP32 (own implementation) vs Arduino (DNSServer library) split.
#if defined(USE_CAPTIVE_PORTAL) || defined(USE_WEBSERVER_CAPTIVE)
#include <memory>
#include "esphome/components/network/ip_address.h"
#include "esphome/core/helpers.h"
#include "esphome/core/progmem.h"
#if defined(USE_ESP32)
#include "dns_server_esp32_idf.h"
#elif defined(USE_ARDUINO)
#include <DNSServer.h>
#endif
namespace esphome::web_server_base {
// The server object only exists while running, so an idle owner (AP not up) pays one pointer.
class CaptiveDNS {
public:
void start(const network::IPAddress &ip) {
if (this->dns_server_ != nullptr)
return;
this->dns_server_ = make_unique<DNSServer>();
#if defined(USE_ESP32)
this->dns_server_->start(ip);
#elif defined(USE_ARDUINO)
this->dns_server_->setErrorReplyCode(DNSReplyCode::NoError);
this->dns_server_->start(53, ESPHOME_F("*"), ip);
#endif
}
void stop() {
if (this->dns_server_ == nullptr)
return;
this->dns_server_->stop();
this->dns_server_ = nullptr;
}
/// Answer one pending query; call from the owner's loop() while running.
void loop() {
if (this->dns_server_ == nullptr)
return;
#if defined(USE_ESP32)
this->dns_server_->process_next_request();
#elif defined(USE_ARDUINO)
this->dns_server_->processNextRequest();
#endif
}
bool is_running() const { return this->dns_server_ != nullptr; }
protected:
// ESP32: web_server_base::DNSServer from dns_server_esp32_idf.h; Arduino: the library class.
std::unique_ptr<DNSServer> dns_server_;
};
} // namespace esphome::web_server_base
#endif // USE_CAPTIVE_PORTAL || USE_WEBSERVER_CAPTIVE
@@ -325,9 +325,9 @@ StringRef AsyncWebServerRequest::url_to(std::span<char, URL_BUF_SIZE> buffer) co
return StringRef(buffer.data(), decoded_len);
}
void AsyncWebServerRequest::redirect(const char *url) {
void AsyncWebServerRequest::redirect(const std::string &url) {
httpd_resp_set_status(*this, "302 Found");
httpd_resp_set_hdr(*this, "Location", url);
httpd_resp_set_hdr(*this, "Location", url.c_str());
httpd_resp_set_hdr(*this, "Connection", "close");
httpd_resp_send(*this, nullptr, 0);
}
@@ -126,8 +126,7 @@ class AsyncWebServerRequest {
void requestAuthentication() const;
#endif
void redirect(const char *url);
void redirect(const std::string &url) { this->redirect(url.c_str()); }
void redirect(const std::string &url);
inline void ESPHOME_ALWAYS_INLINE send(AsyncWebServerResponse *response) {
httpd_resp_send(*this, response->get_content_data(), response->get_content_size());
+16 -47
View File
@@ -36,9 +36,6 @@
#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"
@@ -744,9 +741,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
@@ -807,8 +804,8 @@ void WiFiComponent::loop() {
this->check_connecting_finished(now);
break;
}
// 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_();
// 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_();
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
@@ -885,11 +882,13 @@ void WiFiComponent::loop() {
ESP_LOGI(TAG, "Starting fallback AP");
this->setup_ap_config_();
#ifdef USE_CAPTIVE_PORTAL
// 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;
if (captive_portal::global_captive_portal != nullptr) {
// Reset so we force one full scan after captive portal starts
// (previous scans were filtered because captive portal wasn't active yet)
this->has_completed_scan_after_captive_portal_start_ = false;
captive_portal::global_captive_portal->start();
}
#endif
this->start_ap_portal_();
}
}
#endif // USE_WIFI_AP
@@ -1636,8 +1635,10 @@ void WiFiComponent::check_connecting_finished(uint32_t now) {
this->retry_phase_ = WiFiRetryPhase::INITIAL_CONNECT;
this->num_retried_ = 0;
if (this->has_ap()) {
#ifdef USE_WIFI_AP
this->end_ap_portal_();
#ifdef USE_CAPTIVE_PORTAL
if (this->is_captive_portal_active_()) {
captive_portal::global_captive_portal->end();
}
#endif
ESP_LOGD(TAG, "Disabling AP");
this->wifi_mode_({}, false);
@@ -1964,10 +1965,10 @@ bool WiFiComponent::transition_to_phase_(WiFiRetryPhase new_phase) {
break;
case WiFiRetryPhase::RESTARTING_ADAPTER:
// Skip actual adapter restart if a portal/improv is active
// Skip actual adapter restart if captive portal/improv is active
// This allows state machine to reset num_retried_ and trigger fresh scan
// without disrupting the portal/improv connection
if (!this->is_ap_portal_active_() && !this->is_esp32_improv_active_()) {
// without disrupting the captive portal/improv connection
if (!this->is_captive_portal_active_() && !this->is_esp32_improv_active_()) {
this->restart_adapter();
} else {
// Even when skipping full restart, disconnect to clear driver state
@@ -2226,38 +2227,6 @@ 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,12 +796,6 @@ 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,16 +1130,10 @@ bool WiFiComponent::wifi_ap_ip_config_(const optional<ManualIP> &manual_ip) {
return false;
}
#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) {
#if defined(USE_CAPTIVE_PORTAL) && ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 4, 0)
// Configure DHCP Option 114 (Captive Portal URI) if captive portal is enabled
// This provides a standards-compliant way for clients to discover the captive portal
if (captive_portal::global_captive_portal != nullptr) {
// Buffer must be static - dhcps_set_option_info stores pointer, doesn't copy
static char captive_portal_uri[24]; // "http://" (7) + IPv4 max (15) + null
memcpy(captive_portal_uri, "http://", 7); // NOLINT(bugprone-not-null-terminated-result) - str_to null-terminates
-3
View File
@@ -366,7 +366,6 @@
#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
@@ -480,7 +479,6 @@
#define USE_WEBSERVER
#define USE_WEBSERVER_AUTH
#define USE_WEBSERVER_AUTH_DIGEST
#define USE_WEBSERVER_CAPTIVE
#define USE_WEBSERVER_PORT 80 // NOLINT
#endif
@@ -538,7 +536,6 @@
#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
+66 -18
View File
@@ -16,7 +16,7 @@ name and promote with an atomic rename.
from __future__ import annotations
from collections.abc import Iterator
from collections.abc import Iterable, 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
# Concurrent registry resolutions / HEAD probes (each is network-bound)
_RESOLVE_WORKERS = 8
# Cap for network-bound work: resolutions, HEAD probes, clone floor
_NETWORK_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(_RESOLVE_WORKERS, len(pending))) as ex:
with ThreadPoolExecutor(max_workers=min(_NETWORK_WORKERS, len(pending))) as ex:
results = list(ex.map(_resolve, pending))
jobs: list[tuple[str, int, Any]] = []
installable: list[tuple[str, Any]] = []
@@ -374,14 +374,47 @@ 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 whose archives will be
installable.
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.
"""
from esphome.net_retry import fetch_with_retry, http_request
@@ -389,13 +422,25 @@ def _uri_jobs(
installable: list[tuple[str, Any]] = []
for spec in specs:
url = spec.uri
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 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 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():
@@ -409,7 +454,7 @@ def _uri_jobs(
if str(dl_path) in seen:
continue # another spec already claimed this .part
seen.add(str(dl_path))
candidates.append((spec.name, url, dl_path, spec))
candidates.append((name, url, dl_path, spec))
errors: list[str] = []
@@ -435,7 +480,7 @@ def _uri_jobs(
if not candidates:
return [], 0, installable
with ThreadPoolExecutor(max_workers=min(_RESOLVE_WORKERS, len(candidates))) as ex:
with ThreadPoolExecutor(max_workers=min(_NETWORK_WORKERS, len(candidates))) as ex:
sizes = list(ex.map(_head_size, [url for _, url, _, _ in candidates]))
jobs: list[tuple[str, int, Any]] = []
failed = 0
@@ -583,10 +628,6 @@ 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]:
@@ -701,7 +742,14 @@ def _preinstall(
would hang, not fail). Waves skip dependencies; the installed
manifests feed the next wave. Any failure falls back to pio run.
"""
workers = min(get_usable_cpu_count(), len(entries))
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)
)
# One manager per worker (_install mutates instance state); built
# serially because construction rewires the shared manager logger
managers: SimpleQueue = SimpleQueue()
@@ -733,7 +781,7 @@ def _preinstall(
raise
_LOGGER.info(
"Installing %d PlatformIO package(s) with %d extraction worker(s): %s",
"Installing %d PlatformIO package(s) with %d worker(s): %s",
len(entries),
workers,
", ".join(name for name, *_ in entries),
@@ -1,11 +0,0 @@
# 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
@@ -1,9 +0,0 @@
# 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,2 +0,0 @@
packages:
web_server: !include common-ap-fallback.yaml
@@ -1,2 +0,0 @@
packages:
web_server: !include common-ap-mode.yaml
@@ -1,2 +0,0 @@
packages:
web_server: !include common-ap-mode.yaml
+140 -141
View File
@@ -1,143 +1,142 @@
{
"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
"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
}
@@ -1,150 +0,0 @@
"""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
+125 -18
View File
@@ -588,7 +588,8 @@ 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; git and unreachable URLs are skipped."""
"""HEAD sizes direct-URL specs; VCS specs skip the download but are
still installable (the pre-install clones them in parallel)."""
m = _fake_manager(tmp_path)
resp = MagicMock()
resp.headers = {"content-length": "2222"}
@@ -597,15 +598,14 @@ 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"),
_FakeSpec(uri="https://x/repo.git#v1", name="barevcs"),
_FakeSpec(uri="git+https://x/repo.git", name="repo", custom_name=True),
_FakeSpec(name="registry"),
],
set(),
)
assert failed == 0
assert [(n, s) for n, s, _ in jobs] == [("big", 2222)]
assert [n for n, _ in installable] == ["big"]
assert [n for n, _ in installable] == ["repo", "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,6 +614,67 @@ 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:
@@ -1600,12 +1661,9 @@ def test_preinstall_dependency_wave_skips_seen_names(tmp_path: Path) -> None:
assert installed == ["noise-c"]
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()
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."""
class _WaveManager:
package_dir = str(tmp_path)
@@ -1636,20 +1694,59 @@ def test_preinstall_uses_distinct_managers_in_parallel(tmp_path: Path) -> None:
return None
def _install(self, spec, skip_dependencies, compatibility=None) -> None:
used.add(id(self))
barrier.wait()
on_install(self, spec)
seed = _WaveManager(str(tmp_path))
with patch.object(pf, "get_usable_cpu_count", return_value=2):
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):
pf._preinstall(
seed,
[
("a@1", _FakeSpec(name="a")),
("b@1", _FakeSpec(name="b")),
("zip", _FakeSpec(uri="https://x/a.zip", name="zip")),
("repo", _FakeSpec(uri="git+https://x/repo.git", name="repo")),
],
)
assert len(used) == 2
assert id(seed) not in used
assert order == ["repo", "zip"]
def test_sibling_manager_and_sigterm() -> None:
@@ -1803,3 +1900,13 @@ 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()