Merge pull request #11980 from esphome/bump-2025.11.0b4

2025.11.0b4
This commit is contained in:
Jesse Hills
2025-11-19 13:22:09 +13:00
committed by GitHub
23 changed files with 216 additions and 47 deletions

View File

@@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome
# could be handy for archiving the generated documentation or if some version # could be handy for archiving the generated documentation or if some version
# control system is used. # control system is used.
PROJECT_NUMBER = 2025.11.0b3 PROJECT_NUMBER = 2025.11.0b4
# Using the PROJECT_BRIEF tag one can provide an optional one line description # Using the PROJECT_BRIEF tag one can provide an optional one line description
# for a project that appears at the top of each page and should give viewer a # for a project that appears at the top of each page and should give viewer a

View File

@@ -1,9 +1,12 @@
import logging
import esphome.codegen as cg import esphome.codegen as cg
from esphome.components import web_server_base from esphome.components import web_server_base
from esphome.components.web_server_base import CONF_WEB_SERVER_BASE_ID from esphome.components.web_server_base import CONF_WEB_SERVER_BASE_ID
from esphome.config_helpers import filter_source_files_from_platform from esphome.config_helpers import filter_source_files_from_platform
import esphome.config_validation as cv import esphome.config_validation as cv
from esphome.const import ( from esphome.const import (
CONF_AP,
CONF_ID, CONF_ID,
PLATFORM_BK72XX, PLATFORM_BK72XX,
PLATFORM_ESP32, PLATFORM_ESP32,
@@ -14,6 +17,10 @@ from esphome.const import (
) )
from esphome.core import CORE, coroutine_with_priority from esphome.core import CORE, coroutine_with_priority
from esphome.coroutine import CoroPriority from esphome.coroutine import CoroPriority
import esphome.final_validate as fv
from esphome.types import ConfigType
_LOGGER = logging.getLogger(__name__)
def AUTO_LOAD() -> list[str]: def AUTO_LOAD() -> list[str]:
@@ -50,6 +57,37 @@ CONFIG_SCHEMA = cv.All(
) )
def _final_validate(config: ConfigType) -> ConfigType:
full_config = fv.full_config.get()
wifi_conf = full_config.get("wifi")
if wifi_conf is None:
# This shouldn't happen due to DEPENDENCIES = ["wifi"], but check anyway
raise cv.Invalid("Captive portal requires the wifi component to be configured")
if CONF_AP not in wifi_conf:
_LOGGER.warning(
"Captive portal is enabled but no WiFi AP is configured. "
"The captive portal will not be accessible. "
"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 additional 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.
from esphome.components import socket
socket.consume_sockets(4, "captive_portal")(config)
return config
FINAL_VALIDATE_SCHEMA = _final_validate
@coroutine_with_priority(CoroPriority.CAPTIVE_PORTAL) @coroutine_with_priority(CoroPriority.CAPTIVE_PORTAL)
async def to_code(config): async def to_code(config):
paren = await cg.get_variable(config[CONF_WEB_SERVER_BASE_ID]) paren = await cg.get_variable(config[CONF_WEB_SERVER_BASE_ID])

View File

@@ -50,8 +50,8 @@ void CaptivePortal::handle_wifisave(AsyncWebServerRequest *request) {
ESP_LOGI(TAG, "Requested WiFi Settings Change:"); ESP_LOGI(TAG, "Requested WiFi Settings Change:");
ESP_LOGI(TAG, " SSID='%s'", ssid.c_str()); ESP_LOGI(TAG, " SSID='%s'", ssid.c_str());
ESP_LOGI(TAG, " Password=" LOG_SECRET("'%s'"), psk.c_str()); ESP_LOGI(TAG, " Password=" LOG_SECRET("'%s'"), psk.c_str());
wifi::global_wifi_component->save_wifi_sta(ssid, psk); // Defer save to main loop thread to avoid NVS operations from HTTP thread
wifi::global_wifi_component->start_scanning(); this->defer([ssid, psk]() { wifi::global_wifi_component->save_wifi_sta(ssid, psk); });
request->redirect(ESPHOME_F("/?save")); request->redirect(ESPHOME_F("/?save"));
} }
@@ -63,6 +63,12 @@ void CaptivePortal::start() {
this->base_->init(); this->base_->init();
if (!this->initialized_) { if (!this->initialized_) {
this->base_->add_handler(this); this->base_->add_handler(this);
#ifdef USE_ESP32
// Enable LRU socket purging to handle captive portal detection probe bursts
// OS captive portal detection makes many simultaneous HTTP requests which can
// exhaust sockets. LRU purging automatically closes oldest idle connections.
this->base_->get_server()->set_lru_purge_enable(true);
#endif
} }
network::IPAddress ip = wifi::global_wifi_component->wifi_soft_ap_ip(); network::IPAddress ip = wifi::global_wifi_component->wifi_soft_ap_ip();

View File

@@ -40,6 +40,10 @@ class CaptivePortal : public AsyncWebHandler, public Component {
void end() { void end() {
this->active_ = false; this->active_ = false;
this->disable_loop(); // Stop processing DNS requests this->disable_loop(); // Stop processing DNS requests
#ifdef USE_ESP32
// Disable LRU socket purging now that captive portal is done
this->base_->get_server()->set_lru_purge_enable(false);
#endif
this->base_->deinit(); this->base_->deinit();
if (this->dns_server_ != nullptr) { if (this->dns_server_ != nullptr) {
this->dns_server_->stop(); this->dns_server_->stop();

View File

@@ -931,6 +931,12 @@ async def to_code(config):
add_idf_sdkconfig_option("CONFIG_MBEDTLS_CERTIFICATE_BUNDLE", True) add_idf_sdkconfig_option("CONFIG_MBEDTLS_CERTIFICATE_BUNDLE", True)
add_idf_sdkconfig_option("CONFIG_ESP_PHY_REDUCE_TX_POWER", True) add_idf_sdkconfig_option("CONFIG_ESP_PHY_REDUCE_TX_POWER", True)
# ESP32-S2 Arduino: Disable USB Serial on boot to avoid TinyUSB dependency
if get_esp32_variant() == VARIANT_ESP32S2:
cg.add_build_unflag("-DARDUINO_USB_CDC_ON_BOOT=1")
cg.add_build_unflag("-DARDUINO_USB_CDC_ON_BOOT=0")
cg.add_build_flag("-DARDUINO_USB_CDC_ON_BOOT=0")
cg.add_build_flag("-Wno-nonnull-compare") cg.add_build_flag("-Wno-nonnull-compare")
add_idf_sdkconfig_option(f"CONFIG_IDF_TARGET_{variant}", True) add_idf_sdkconfig_option(f"CONFIG_IDF_TARGET_{variant}", True)

View File

@@ -20,6 +20,10 @@ CONF_ON_STOP = "on_stop"
CONF_STATUS_INDICATOR = "status_indicator" CONF_STATUS_INDICATOR = "status_indicator"
CONF_WIFI_TIMEOUT = "wifi_timeout" CONF_WIFI_TIMEOUT = "wifi_timeout"
# Default WiFi timeout - aligned with WiFi component ap_timeout
# Allows sufficient time to try all BSSIDs before starting provisioning mode
DEFAULT_WIFI_TIMEOUT = "90s"
improv_ns = cg.esphome_ns.namespace("improv") improv_ns = cg.esphome_ns.namespace("improv")
Error = improv_ns.enum("Error") Error = improv_ns.enum("Error")
@@ -59,7 +63,7 @@ CONFIG_SCHEMA = (
CONF_AUTHORIZED_DURATION, default="1min" CONF_AUTHORIZED_DURATION, default="1min"
): cv.positive_time_period_milliseconds, ): cv.positive_time_period_milliseconds,
cv.Optional( cv.Optional(
CONF_WIFI_TIMEOUT, default="1min" CONF_WIFI_TIMEOUT, default=DEFAULT_WIFI_TIMEOUT
): cv.positive_time_period_milliseconds, ): cv.positive_time_period_milliseconds,
cv.Optional(CONF_ON_PROVISIONED): automation.validate_automation( cv.Optional(CONF_ON_PROVISIONED): automation.validate_automation(
{ {

View File

@@ -127,6 +127,7 @@ void ESP32ImprovComponent::loop() {
// Set initial state based on whether we have an authorizer // Set initial state based on whether we have an authorizer
this->set_state_(this->get_initial_state_(), false); this->set_state_(this->get_initial_state_(), false);
this->set_error_(improv::ERROR_NONE); this->set_error_(improv::ERROR_NONE);
this->should_start_ = false; // Clear flag after starting
ESP_LOGD(TAG, "Service started!"); ESP_LOGD(TAG, "Service started!");
} }
} }

View File

@@ -45,6 +45,7 @@ class ESP32ImprovComponent : public Component, public improv_base::ImprovBase {
void start(); void start();
void stop(); void stop();
bool is_active() const { return this->state_ != improv::STATE_STOPPED; } bool is_active() const { return this->state_ != improv::STATE_STOPPED; }
bool should_start() const { return this->should_start_; }
#ifdef USE_ESP32_IMPROV_STATE_CALLBACK #ifdef USE_ESP32_IMPROV_STATE_CALLBACK
void add_on_state_callback(std::function<void(improv::State, improv::Error)> &&callback) { void add_on_state_callback(std::function<void(improv::State, improv::Error)> &&callback) {

View File

@@ -1,6 +1,7 @@
from esphome import automation from esphome import automation
import esphome.config_validation as cv import esphome.config_validation as cv
from esphome.const import CONF_ID, CONF_RANGE_FROM, CONF_RANGE_TO, CONF_STEP, CONF_VALUE from esphome.const import CONF_ID, CONF_RANGE_FROM, CONF_RANGE_TO, CONF_STEP, CONF_VALUE
from esphome.cpp_generator import MockObj
from ..automation import action_to_code from ..automation import action_to_code
from ..defines import ( from ..defines import (
@@ -114,7 +115,9 @@ class SpinboxType(WidgetType):
w.obj, digits, digits - config[CONF_DECIMAL_PLACES] w.obj, digits, digits - config[CONF_DECIMAL_PLACES]
) )
if (value := config.get(CONF_VALUE)) is not None: if (value := config.get(CONF_VALUE)) is not None:
lv.spinbox_set_value(w.obj, await lv_float.process(value)) lv.spinbox_set_value(
w.obj, MockObj(await lv_float.process(value)) * w.get_scale()
)
def get_scale(self, config): def get_scale(self, config):
return 10 ** config[CONF_DECIMAL_PLACES] return 10 ** config[CONF_DECIMAL_PLACES]

View File

@@ -350,6 +350,7 @@ void MipiRgb::dump_config() {
"\n Width: %u" "\n Width: %u"
"\n Height: %u" "\n Height: %u"
"\n Rotation: %d degrees" "\n Rotation: %d degrees"
"\n PCLK Inverted: %s"
"\n HSync Pulse Width: %u" "\n HSync Pulse Width: %u"
"\n HSync Back Porch: %u" "\n HSync Back Porch: %u"
"\n HSync Front Porch: %u" "\n HSync Front Porch: %u"
@@ -357,18 +358,18 @@ void MipiRgb::dump_config() {
"\n VSync Back Porch: %u" "\n VSync Back Porch: %u"
"\n VSync Front Porch: %u" "\n VSync Front Porch: %u"
"\n Invert Colors: %s" "\n Invert Colors: %s"
"\n Pixel Clock: %dMHz" "\n Pixel Clock: %uMHz"
"\n Reset Pin: %s" "\n Reset Pin: %s"
"\n DE Pin: %s" "\n DE Pin: %s"
"\n PCLK Pin: %s" "\n PCLK Pin: %s"
"\n HSYNC Pin: %s" "\n HSYNC Pin: %s"
"\n VSYNC Pin: %s", "\n VSYNC Pin: %s",
this->model_, this->width_, this->height_, this->rotation_, this->hsync_pulse_width_, this->model_, this->width_, this->height_, this->rotation_, YESNO(this->pclk_inverted_),
this->hsync_back_porch_, this->hsync_front_porch_, this->vsync_pulse_width_, this->vsync_back_porch_, this->hsync_pulse_width_, this->hsync_back_porch_, this->hsync_front_porch_, this->vsync_pulse_width_,
this->vsync_front_porch_, YESNO(this->invert_colors_), this->pclk_frequency_ / 1000000, this->vsync_back_porch_, this->vsync_front_porch_, YESNO(this->invert_colors_),
get_pin_name(this->reset_pin_).c_str(), get_pin_name(this->de_pin_).c_str(), (unsigned) (this->pclk_frequency_ / 1000000), get_pin_name(this->reset_pin_).c_str(),
get_pin_name(this->pclk_pin_).c_str(), get_pin_name(this->hsync_pin_).c_str(), get_pin_name(this->de_pin_).c_str(), get_pin_name(this->pclk_pin_).c_str(),
get_pin_name(this->vsync_pin_).c_str()); get_pin_name(this->hsync_pin_).c_str(), get_pin_name(this->vsync_pin_).c_str());
if (this->madctl_ & MADCTL_BGR) { if (this->madctl_ & MADCTL_BGR) {
this->dump_pins_(8, 13, "Blue", 0); this->dump_pins_(8, 13, "Blue", 0);

View File

@@ -11,6 +11,7 @@ st7701s.extend(
vsync_pin=17, vsync_pin=17,
pclk_pin=21, pclk_pin=21,
pclk_frequency="12MHz", pclk_frequency="12MHz",
pclk_inverted=False,
pixel_mode="18bit", pixel_mode="18bit",
mirror_x=True, mirror_x=True,
mirror_y=True, mirror_y=True,

View File

@@ -73,17 +73,17 @@ void SFA30Component::update() {
} }
if (this->formaldehyde_sensor_ != nullptr) { if (this->formaldehyde_sensor_ != nullptr) {
const float formaldehyde = raw_data[0] / 5.0f; const float formaldehyde = static_cast<int16_t>(raw_data[0]) / 5.0f;
this->formaldehyde_sensor_->publish_state(formaldehyde); this->formaldehyde_sensor_->publish_state(formaldehyde);
} }
if (this->humidity_sensor_ != nullptr) { if (this->humidity_sensor_ != nullptr) {
const float humidity = raw_data[1] / 100.0f; const float humidity = static_cast<int16_t>(raw_data[1]) / 100.0f;
this->humidity_sensor_->publish_state(humidity); this->humidity_sensor_->publish_state(humidity);
} }
if (this->temperature_sensor_ != nullptr) { if (this->temperature_sensor_ != nullptr) {
const float temperature = raw_data[2] / 200.0f; const float temperature = static_cast<int16_t>(raw_data[2]) / 200.0f;
this->temperature_sensor_->publish_state(temperature); this->temperature_sensor_->publish_state(temperature);
} }

View File

@@ -94,6 +94,18 @@ void AsyncWebServer::end() {
} }
} }
void AsyncWebServer::set_lru_purge_enable(bool enable) {
if (this->lru_purge_enable_ == enable) {
return; // No change needed
}
this->lru_purge_enable_ = enable;
// If server is already running, restart it with new config
if (this->server_) {
this->end();
this->begin();
}
}
void AsyncWebServer::begin() { void AsyncWebServer::begin() {
if (this->server_) { if (this->server_) {
this->end(); this->end();
@@ -101,6 +113,8 @@ void AsyncWebServer::begin() {
httpd_config_t config = HTTPD_DEFAULT_CONFIG(); httpd_config_t config = HTTPD_DEFAULT_CONFIG();
config.server_port = this->port_; config.server_port = this->port_;
config.uri_match_fn = [](const char * /*unused*/, const char * /*unused*/, size_t /*unused*/) { return true; }; config.uri_match_fn = [](const char * /*unused*/, const char * /*unused*/, size_t /*unused*/) { return true; };
// Enable LRU purging if requested (e.g., by captive portal to handle probe bursts)
config.lru_purge_enable = this->lru_purge_enable_;
if (httpd_start(&this->server_, &config) == ESP_OK) { if (httpd_start(&this->server_, &config) == ESP_OK) {
const httpd_uri_t handler_get = { const httpd_uri_t handler_get = {
.uri = "", .uri = "",
@@ -242,6 +256,7 @@ void AsyncWebServerRequest::send(int code, const char *content_type, const char
void AsyncWebServerRequest::redirect(const std::string &url) { void AsyncWebServerRequest::redirect(const std::string &url) {
httpd_resp_set_status(*this, "302 Found"); httpd_resp_set_status(*this, "302 Found");
httpd_resp_set_hdr(*this, "Location", url.c_str()); httpd_resp_set_hdr(*this, "Location", url.c_str());
httpd_resp_set_hdr(*this, "Connection", "close");
httpd_resp_send(*this, nullptr, 0); httpd_resp_send(*this, nullptr, 0);
} }

View File

@@ -199,9 +199,13 @@ class AsyncWebServer {
return *handler; return *handler;
} }
void set_lru_purge_enable(bool enable);
httpd_handle_t get_server() { return this->server_; }
protected: protected:
uint16_t port_{}; uint16_t port_{};
httpd_handle_t server_{}; httpd_handle_t server_{};
bool lru_purge_enable_{false};
static esp_err_t request_handler(httpd_req_t *r); static esp_err_t request_handler(httpd_req_t *r);
static esp_err_t request_post_handler(httpd_req_t *r); static esp_err_t request_post_handler(httpd_req_t *r);
esp_err_t request_handler_(AsyncWebServerRequest *request) const; esp_err_t request_handler_(AsyncWebServerRequest *request) const;

View File

@@ -69,6 +69,12 @@ CONF_MIN_AUTH_MODE = "min_auth_mode"
# Limited to 127 because selected_sta_index_ is int8_t in C++ # Limited to 127 because selected_sta_index_ is int8_t in C++
MAX_WIFI_NETWORKS = 127 MAX_WIFI_NETWORKS = 127
# Default AP timeout - allows sufficient time to try all BSSIDs during initial connection
# After AP starts, WiFi scanning is skipped to avoid disrupting the AP, so we only
# get best-effort connection attempts. Longer timeout ensures we exhaust all options
# before falling back to AP mode. Aligned with improv wifi_timeout default.
DEFAULT_AP_TIMEOUT = "90s"
wifi_ns = cg.esphome_ns.namespace("wifi") wifi_ns = cg.esphome_ns.namespace("wifi")
EAPAuth = wifi_ns.struct("EAPAuth") EAPAuth = wifi_ns.struct("EAPAuth")
ManualIP = wifi_ns.struct("ManualIP") ManualIP = wifi_ns.struct("ManualIP")
@@ -177,7 +183,7 @@ CONF_AP_TIMEOUT = "ap_timeout"
WIFI_NETWORK_AP = WIFI_NETWORK_BASE.extend( WIFI_NETWORK_AP = WIFI_NETWORK_BASE.extend(
{ {
cv.Optional( cv.Optional(
CONF_AP_TIMEOUT, default="1min" CONF_AP_TIMEOUT, default=DEFAULT_AP_TIMEOUT
): cv.positive_time_period_milliseconds, ): cv.positive_time_period_milliseconds,
} }
) )

View File

@@ -199,7 +199,12 @@ static constexpr uint8_t WIFI_RETRY_COUNT_PER_AP = 1;
/// Cooldown duration in milliseconds after adapter restart or repeated failures /// Cooldown duration in milliseconds after adapter restart or repeated failures
/// Allows WiFi hardware to stabilize before next connection attempt /// Allows WiFi hardware to stabilize before next connection attempt
static constexpr uint32_t WIFI_COOLDOWN_DURATION_MS = 1000; static constexpr uint32_t WIFI_COOLDOWN_DURATION_MS = 500;
/// Cooldown duration when fallback AP is active and captive portal may be running
/// Longer interval gives users time to configure WiFi without constant connection attempts
/// While connecting, WiFi can't beacon the AP properly, so needs longer cooldown
static constexpr uint32_t WIFI_COOLDOWN_WITH_AP_ACTIVE_MS = 30000;
static constexpr uint8_t get_max_retries_for_phase(WiFiRetryPhase phase) { static constexpr uint8_t get_max_retries_for_phase(WiFiRetryPhase phase) {
switch (phase) { switch (phase) {
@@ -275,7 +280,9 @@ int8_t WiFiComponent::find_next_hidden_sta_(int8_t start_index) {
} }
} }
if (!this->ssid_was_seen_in_scan_(sta.get_ssid())) { // If we didn't scan this cycle, treat all networks as potentially hidden
// Otherwise, only retry networks that weren't seen in the scan
if (!this->did_scan_this_cycle_ || !this->ssid_was_seen_in_scan_(sta.get_ssid())) {
ESP_LOGD(TAG, "Hidden candidate " LOG_SECRET("'%s'") " at index %d", sta.get_ssid().c_str(), static_cast<int>(i)); ESP_LOGD(TAG, "Hidden candidate " LOG_SECRET("'%s'") " at index %d", sta.get_ssid().c_str(), static_cast<int>(i));
return static_cast<int8_t>(i); return static_cast<int8_t>(i);
} }
@@ -417,10 +424,6 @@ void WiFiComponent::start() {
void WiFiComponent::restart_adapter() { void WiFiComponent::restart_adapter() {
ESP_LOGW(TAG, "Restarting adapter"); ESP_LOGW(TAG, "Restarting adapter");
this->wifi_mode_(false, {}); this->wifi_mode_(false, {});
// Enter cooldown state to allow WiFi hardware to stabilize after restart
// Don't set retry_phase_ or num_retried_ here - state machine handles transitions
this->state_ = WIFI_COMPONENT_STATE_COOLDOWN;
this->action_started_ = millis();
this->error_from_callback_ = false; this->error_from_callback_ = false;
} }
@@ -441,7 +444,16 @@ void WiFiComponent::loop() {
switch (this->state_) { switch (this->state_) {
case WIFI_COMPONENT_STATE_COOLDOWN: { case WIFI_COMPONENT_STATE_COOLDOWN: {
this->status_set_warning(LOG_STR("waiting to reconnect")); this->status_set_warning(LOG_STR("waiting to reconnect"));
if (now - this->action_started_ > WIFI_COOLDOWN_DURATION_MS) { // Skip cooldown if new credentials were provided while connecting
if (this->skip_cooldown_next_cycle_) {
this->skip_cooldown_next_cycle_ = false;
this->check_connecting_finished();
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_();
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 // After cooldown we either restarted the adapter because of
// a failure, or something tried to connect over and over // a failure, or something tried to connect over and over
// so we entered cooldown. In both cases we call // so we entered cooldown. In both cases we call
@@ -495,7 +507,8 @@ void WiFiComponent::loop() {
#endif // USE_WIFI_AP #endif // USE_WIFI_AP
#ifdef USE_IMPROV #ifdef USE_IMPROV
if (esp32_improv::global_improv_component != nullptr && !esp32_improv::global_improv_component->is_active()) { if (esp32_improv::global_improv_component != nullptr && !esp32_improv::global_improv_component->is_active() &&
!esp32_improv::global_improv_component->should_start()) {
if (now - this->last_connected_ > esp32_improv::global_improv_component->get_wifi_timeout()) { if (now - this->last_connected_ > esp32_improv::global_improv_component->get_wifi_timeout()) {
if (this->wifi_mode_(true, {})) if (this->wifi_mode_(true, {}))
esp32_improv::global_improv_component->start(); esp32_improv::global_improv_component->start();
@@ -605,6 +618,8 @@ void WiFiComponent::set_sta(const WiFiAP &ap) {
this->init_sta(1); this->init_sta(1);
this->add_sta(ap); this->add_sta(ap);
this->selected_sta_index_ = 0; this->selected_sta_index_ = 0;
// When new credentials are set (e.g., from improv), skip cooldown to retry immediately
this->skip_cooldown_next_cycle_ = true;
} }
WiFiAP WiFiComponent::build_params_for_current_phase_() { WiFiAP WiFiComponent::build_params_for_current_phase_() {
@@ -666,6 +681,17 @@ void WiFiComponent::save_wifi_sta(const std::string &ssid, const std::string &pa
sta.set_ssid(ssid); sta.set_ssid(ssid);
sta.set_password(password); sta.set_password(password);
this->set_sta(sta); this->set_sta(sta);
// Trigger connection attempt (exits cooldown if needed, no-op if already connecting/connected)
this->connect_soon_();
}
void WiFiComponent::connect_soon_() {
// Only trigger retry if we're in cooldown - if already connecting/connected, do nothing
if (this->state_ == WIFI_COMPONENT_STATE_COOLDOWN) {
ESP_LOGD(TAG, "Exiting cooldown early due to new WiFi credentials");
this->retry_connect();
}
} }
void WiFiComponent::start_connecting(const WiFiAP &ap) { void WiFiComponent::start_connecting(const WiFiAP &ap) {
@@ -961,6 +987,7 @@ void WiFiComponent::check_scanning_finished() {
return; return;
} }
this->scan_done_ = false; this->scan_done_ = false;
this->did_scan_this_cycle_ = true;
if (this->scan_result_.empty()) { if (this->scan_result_.empty()) {
ESP_LOGW(TAG, "No networks found"); ESP_LOGW(TAG, "No networks found");
@@ -1227,9 +1254,16 @@ WiFiRetryPhase WiFiComponent::determine_next_phase_() {
return WiFiRetryPhase::RESTARTING_ADAPTER; return WiFiRetryPhase::RESTARTING_ADAPTER;
case WiFiRetryPhase::RESTARTING_ADAPTER: case WiFiRetryPhase::RESTARTING_ADAPTER:
// After restart, go back to explicit hidden if we went through it initially, otherwise scan // After restart, go back to explicit hidden if we went through it initially
return this->went_through_explicit_hidden_phase_() ? WiFiRetryPhase::EXPLICIT_HIDDEN if (this->went_through_explicit_hidden_phase_()) {
: WiFiRetryPhase::SCAN_CONNECTING; return WiFiRetryPhase::EXPLICIT_HIDDEN;
}
// Skip scanning when captive portal/improv is active to avoid disrupting AP
// Even passive scans can cause brief AP disconnections on ESP32
if (this->is_captive_portal_active_() || this->is_esp32_improv_active_()) {
return WiFiRetryPhase::RETRY_HIDDEN;
}
return WiFiRetryPhase::SCAN_CONNECTING;
} }
// Should never reach here // Should never reach here
@@ -1317,6 +1351,12 @@ bool WiFiComponent::transition_to_phase_(WiFiRetryPhase new_phase) {
if (!this->is_captive_portal_active_() && !this->is_esp32_improv_active_()) { if (!this->is_captive_portal_active_() && !this->is_esp32_improv_active_()) {
this->restart_adapter(); this->restart_adapter();
} }
// Clear scan flag - we're starting a new retry cycle
this->did_scan_this_cycle_ = false;
// Always enter cooldown after restart (or skip-restart) to allow stabilization
// Use extended cooldown when AP is active to avoid constant scanning that blocks DNS
this->state_ = WIFI_COMPONENT_STATE_COOLDOWN;
this->action_started_ = millis();
// Return true to indicate we should wait (go to COOLDOWN) instead of immediately connecting // Return true to indicate we should wait (go to COOLDOWN) instead of immediately connecting
return true; return true;

View File

@@ -291,6 +291,7 @@ class WiFiComponent : public Component {
void set_passive_scan(bool passive); void set_passive_scan(bool passive);
void save_wifi_sta(const std::string &ssid, const std::string &password); void save_wifi_sta(const std::string &ssid, const std::string &password);
// ========== INTERNAL METHODS ========== // ========== INTERNAL METHODS ==========
// (In most use cases you won't need these) // (In most use cases you won't need these)
/// Setup WiFi interface. /// Setup WiFi interface.
@@ -424,6 +425,8 @@ class WiFiComponent : public Component {
return true; return true;
} }
void connect_soon_();
void wifi_loop_(); void wifi_loop_();
bool wifi_mode_(optional<bool> sta, optional<bool> ap); bool wifi_mode_(optional<bool> sta, optional<bool> ap);
bool wifi_sta_pre_setup_(); bool wifi_sta_pre_setup_();
@@ -529,6 +532,8 @@ class WiFiComponent : public Component {
bool enable_on_boot_; bool enable_on_boot_;
bool got_ipv4_address_{false}; bool got_ipv4_address_{false};
bool keep_scan_results_{false}; bool keep_scan_results_{false};
bool did_scan_this_cycle_{false};
bool skip_cooldown_next_cycle_{false};
// Pointers at the end (naturally aligned) // Pointers at the end (naturally aligned)
Trigger<> *connect_trigger_{new Trigger<>()}; Trigger<> *connect_trigger_{new Trigger<>()};

View File

@@ -4,7 +4,7 @@ from enum import Enum
from esphome.enum import StrEnum from esphome.enum import StrEnum
__version__ = "2025.11.0b3" __version__ = "2025.11.0b4"
ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_"
VALID_SUBSTITUTIONS_CHARACTERS = ( VALID_SUBSTITUTIONS_CHARACTERS = (
@@ -336,6 +336,7 @@ CONF_ENERGY = "energy"
CONF_ENTITY_CATEGORY = "entity_category" CONF_ENTITY_CATEGORY = "entity_category"
CONF_ENTITY_ID = "entity_id" CONF_ENTITY_ID = "entity_id"
CONF_ENUM_DATAPOINT = "enum_datapoint" CONF_ENUM_DATAPOINT = "enum_datapoint"
CONF_ENVIRONMENT_VARIABLES = "environment_variables"
CONF_EQUATION = "equation" CONF_EQUATION = "equation"
CONF_ESP8266_DISABLE_SSL_SUPPORT = "esp8266_disable_ssl_support" CONF_ESP8266_DISABLE_SSL_SUPPORT = "esp8266_disable_ssl_support"
CONF_ESPHOME = "esphome" CONF_ESPHOME = "esphome"

View File

@@ -17,6 +17,7 @@ from esphome.const import (
CONF_COMPILE_PROCESS_LIMIT, CONF_COMPILE_PROCESS_LIMIT,
CONF_DEBUG_SCHEDULER, CONF_DEBUG_SCHEDULER,
CONF_DEVICES, CONF_DEVICES,
CONF_ENVIRONMENT_VARIABLES,
CONF_ESPHOME, CONF_ESPHOME,
CONF_FRIENDLY_NAME, CONF_FRIENDLY_NAME,
CONF_ID, CONF_ID,
@@ -215,6 +216,11 @@ CONFIG_SCHEMA = cv.All(
cv.string_strict: cv.Any([cv.string], cv.string), cv.string_strict: cv.Any([cv.string], cv.string),
} }
), ),
cv.Optional(CONF_ENVIRONMENT_VARIABLES, default={}): cv.Schema(
{
cv.string_strict: cv.string,
}
),
cv.Optional(CONF_ON_BOOT): automation.validate_automation( cv.Optional(CONF_ON_BOOT): automation.validate_automation(
{ {
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(StartupTrigger), cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(StartupTrigger),
@@ -426,6 +432,12 @@ async def _add_platformio_options(pio_options):
cg.add_platformio_option(key, val) cg.add_platformio_option(key, val)
@coroutine_with_priority(CoroPriority.FINAL)
async def _add_environment_variables(env_vars: dict[str, str]) -> None:
# Set environment variables for the build process
os.environ.update(env_vars)
@coroutine_with_priority(CoroPriority.AUTOMATION) @coroutine_with_priority(CoroPriority.AUTOMATION)
async def _add_automations(config): async def _add_automations(config):
for conf in config.get(CONF_ON_BOOT, []): for conf in config.get(CONF_ON_BOOT, []):
@@ -563,6 +575,9 @@ async def to_code(config: ConfigType) -> None:
if config[CONF_PLATFORMIO_OPTIONS]: if config[CONF_PLATFORMIO_OPTIONS]:
CORE.add_job(_add_platformio_options, config[CONF_PLATFORMIO_OPTIONS]) CORE.add_job(_add_platformio_options, config[CONF_PLATFORMIO_OPTIONS])
if config[CONF_ENVIRONMENT_VARIABLES]:
CORE.add_job(_add_environment_variables, config[CONF_ENVIRONMENT_VARIABLES])
# Process areas # Process areas
all_areas: list[dict[str, str | core.ID]] = [] all_areas: list[dict[str, str | core.ID]] = []
if CONF_AREA in config: if CONF_AREA in config:

View File

@@ -154,8 +154,8 @@ void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type
// For retries, check if there's a cancelled timeout first // For retries, check if there's a cancelled timeout first
if (is_retry && name_cstr != nullptr && type == SchedulerItem::TIMEOUT && if (is_retry && name_cstr != nullptr && type == SchedulerItem::TIMEOUT &&
(has_cancelled_timeout_in_container_(this->items_, component, name_cstr, /* match_retry= */ true) || (has_cancelled_timeout_in_container_locked_(this->items_, component, name_cstr, /* match_retry= */ true) ||
has_cancelled_timeout_in_container_(this->to_add_, component, name_cstr, /* match_retry= */ true))) { has_cancelled_timeout_in_container_locked_(this->to_add_, component, name_cstr, /* match_retry= */ true))) {
// Skip scheduling - the retry was cancelled // Skip scheduling - the retry was cancelled
#ifdef ESPHOME_DEBUG_SCHEDULER #ifdef ESPHOME_DEBUG_SCHEDULER
ESP_LOGD(TAG, "Skipping retry '%s' - found cancelled item", name_cstr); ESP_LOGD(TAG, "Skipping retry '%s' - found cancelled item", name_cstr);
@@ -556,7 +556,8 @@ bool HOT Scheduler::cancel_item_locked_(Component *component, const char *name_c
#ifndef ESPHOME_THREAD_SINGLE #ifndef ESPHOME_THREAD_SINGLE
// Mark items in defer queue as cancelled (they'll be skipped when processed) // Mark items in defer queue as cancelled (they'll be skipped when processed)
if (type == SchedulerItem::TIMEOUT) { if (type == SchedulerItem::TIMEOUT) {
total_cancelled += this->mark_matching_items_removed_(this->defer_queue_, component, name_cstr, type, match_retry); total_cancelled +=
this->mark_matching_items_removed_locked_(this->defer_queue_, component, name_cstr, type, match_retry);
} }
#endif /* not ESPHOME_THREAD_SINGLE */ #endif /* not ESPHOME_THREAD_SINGLE */
@@ -565,19 +566,20 @@ bool HOT Scheduler::cancel_item_locked_(Component *component, const char *name_c
// (removing the last element doesn't break heap structure) // (removing the last element doesn't break heap structure)
if (!this->items_.empty()) { if (!this->items_.empty()) {
auto &last_item = this->items_.back(); auto &last_item = this->items_.back();
if (this->matches_item_(last_item, component, name_cstr, type, match_retry)) { if (this->matches_item_locked_(last_item, component, name_cstr, type, match_retry)) {
this->recycle_item_(std::move(this->items_.back())); this->recycle_item_(std::move(this->items_.back()));
this->items_.pop_back(); this->items_.pop_back();
total_cancelled++; total_cancelled++;
} }
// For other items in heap, we can only mark for removal (can't remove from middle of heap) // For other items in heap, we can only mark for removal (can't remove from middle of heap)
size_t heap_cancelled = this->mark_matching_items_removed_(this->items_, component, name_cstr, type, match_retry); size_t heap_cancelled =
this->mark_matching_items_removed_locked_(this->items_, component, name_cstr, type, match_retry);
total_cancelled += heap_cancelled; total_cancelled += heap_cancelled;
this->to_remove_ += heap_cancelled; // Track removals for heap items this->to_remove_ += heap_cancelled; // Track removals for heap items
} }
// Cancel items in to_add_ // Cancel items in to_add_
total_cancelled += this->mark_matching_items_removed_(this->to_add_, component, name_cstr, type, match_retry); total_cancelled += this->mark_matching_items_removed_locked_(this->to_add_, component, name_cstr, type, match_retry);
return total_cancelled > 0; return total_cancelled > 0;
} }

View File

@@ -243,8 +243,18 @@ class Scheduler {
} }
// Helper function to check if item matches criteria for cancellation // Helper function to check if item matches criteria for cancellation
inline bool HOT matches_item_(const std::unique_ptr<SchedulerItem> &item, Component *component, const char *name_cstr, // IMPORTANT: Must be called with scheduler lock held
SchedulerItem::Type type, bool match_retry, bool skip_removed = true) const { inline bool HOT matches_item_locked_(const std::unique_ptr<SchedulerItem> &item, Component *component,
const char *name_cstr, SchedulerItem::Type type, bool match_retry,
bool skip_removed = true) const {
// THREAD SAFETY: Check for nullptr first to prevent LoadProhibited crashes. On multi-threaded
// platforms, items can be moved out of defer_queue_ during processing, leaving nullptr entries.
// PR #11305 added nullptr checks in callers (mark_matching_items_removed_locked_() and
// has_cancelled_timeout_in_container_locked_()), but this check provides defense-in-depth: helper
// functions should be safe regardless of caller behavior.
// Fixes: https://github.com/esphome/esphome/issues/11940
if (!item)
return false;
if (item->component != component || item->type != type || (skip_removed && item->remove) || if (item->component != component || item->type != type || (skip_removed && item->remove) ||
(match_retry && !item->is_retry)) { (match_retry && !item->is_retry)) {
return false; return false;
@@ -304,8 +314,8 @@ class Scheduler {
// SAFETY: Moving out the unique_ptr leaves a nullptr in the vector at defer_queue_front_. // SAFETY: Moving out the unique_ptr leaves a nullptr in the vector at defer_queue_front_.
// This is intentional and safe because: // This is intentional and safe because:
// 1. The vector is only cleaned up by cleanup_defer_queue_locked_() at the end of this function // 1. The vector is only cleaned up by cleanup_defer_queue_locked_() at the end of this function
// 2. Any code iterating defer_queue_ MUST check for nullptr items (see mark_matching_items_removed_ // 2. Any code iterating defer_queue_ MUST check for nullptr items (see mark_matching_items_removed_locked_
// and has_cancelled_timeout_in_container_ in scheduler.h) // and has_cancelled_timeout_in_container_locked_ in scheduler.h)
// 3. The lock protects concurrent access, but the nullptr remains until cleanup // 3. The lock protects concurrent access, but the nullptr remains until cleanup
item = std::move(this->defer_queue_[this->defer_queue_front_]); item = std::move(this->defer_queue_[this->defer_queue_front_]);
this->defer_queue_front_++; this->defer_queue_front_++;
@@ -393,10 +403,10 @@ class Scheduler {
// Helper to mark matching items in a container as removed // Helper to mark matching items in a container as removed
// Returns the number of items marked for removal // Returns the number of items marked for removal
// IMPORTANT: Caller must hold the scheduler lock before calling this function. // IMPORTANT: Must be called with scheduler lock held
template<typename Container> template<typename Container>
size_t mark_matching_items_removed_(Container &container, Component *component, const char *name_cstr, size_t mark_matching_items_removed_locked_(Container &container, Component *component, const char *name_cstr,
SchedulerItem::Type type, bool match_retry) { SchedulerItem::Type type, bool match_retry) {
size_t count = 0; size_t count = 0;
for (auto &item : container) { for (auto &item : container) {
// Skip nullptr items (can happen in defer_queue_ when items are being processed) // Skip nullptr items (can happen in defer_queue_ when items are being processed)
@@ -405,7 +415,7 @@ class Scheduler {
// the vector can still contain nullptr items from the processing loop. This check prevents crashes. // the vector can still contain nullptr items from the processing loop. This check prevents crashes.
if (!item) if (!item)
continue; continue;
if (this->matches_item_(item, component, name_cstr, type, match_retry)) { if (this->matches_item_locked_(item, component, name_cstr, type, match_retry)) {
// Mark item for removal (platform-specific) // Mark item for removal (platform-specific)
this->set_item_removed_(item.get(), true); this->set_item_removed_(item.get(), true);
count++; count++;
@@ -415,9 +425,10 @@ class Scheduler {
} }
// Template helper to check if any item in a container matches our criteria // Template helper to check if any item in a container matches our criteria
// IMPORTANT: Must be called with scheduler lock held
template<typename Container> template<typename Container>
bool has_cancelled_timeout_in_container_(const Container &container, Component *component, const char *name_cstr, bool has_cancelled_timeout_in_container_locked_(const Container &container, Component *component,
bool match_retry) const { const char *name_cstr, bool match_retry) const {
for (const auto &item : container) { for (const auto &item : container) {
// Skip nullptr items (can happen in defer_queue_ when items are being processed) // Skip nullptr items (can happen in defer_queue_ when items are being processed)
// The defer_queue_ uses index-based processing: items are std::moved out but left in the // The defer_queue_ uses index-based processing: items are std::moved out but left in the
@@ -426,8 +437,8 @@ class Scheduler {
if (!item) if (!item)
continue; continue;
if (is_item_removed_(item.get()) && if (is_item_removed_(item.get()) &&
this->matches_item_(item, component, name_cstr, SchedulerItem::TIMEOUT, match_retry, this->matches_item_locked_(item, component, name_cstr, SchedulerItem::TIMEOUT, match_retry,
/* skip_removed= */ false)) { /* skip_removed= */ false)) {
return true; return true;
} }
} }

View File

@@ -2,6 +2,9 @@ esphome:
debug_scheduler: true debug_scheduler: true
platformio_options: platformio_options:
board_build.flash_mode: dio board_build.flash_mode: dio
environment_variables:
TEST_ENV_VAR: "test_value"
BUILD_NUMBER: "12345"
area: area:
id: testing_area id: testing_area
name: Testing Area name: Testing Area

View File

@@ -703,7 +703,9 @@ lvgl:
on_value: on_value:
- lvgl.spinbox.update: - lvgl.spinbox.update:
id: spinbox_id id: spinbox_id
value: !lambda return x; value: !lambda |-
static float yyy = 83.0;
return yyy + .8;
- button: - button:
styles: spin_button styles: spin_button
id: spin_up id: spin_up