Compare commits

...

23 Commits

Author SHA1 Message Date
Jonathan Swoboda
cfd88376b9 Merge pull request #12266 from esphome/bump-2025.11.3
2025.11.3
2025-12-03 11:36:57 -05:00
Jonathan Swoboda
577a6b2941 Bump version to 2025.11.3 2025-12-03 10:50:28 -05:00
Jonathan Swoboda
de68b56c4a [rtl87xx] Fix FreeRTOS version for RTL8720C boards (#12261)
Co-authored-by: Claude <noreply@anthropic.com>
2025-12-03 10:50:28 -05:00
Jonathan Swoboda
ccd23e692b [analog_threshold] Fix oscillation when using invert filter (#12251)
Co-authored-by: Claude <noreply@anthropic.com>
2025-12-03 10:50:28 -05:00
Jonathan Swoboda
1f5a44be3d [rtl87xx] Fix AsyncTCP compilation by upgrading FreeRTOS to 8.2.3 (#12230)
Co-authored-by: Claude <noreply@anthropic.com>
2025-12-03 10:50:28 -05:00
Jonathan Swoboda
1d1e47c757 [core] Fix clean all windows (#12217)
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2025-12-03 10:50:28 -05:00
3fbed1fa79 [ade7953] Apply voltage_gain setting to both channels (#12180) 2025-12-03 10:50:28 -05:00
Jonathan Swoboda
5c71520635 [mopeka_pro_check] Fix negative temperatures (#12198)
Co-authored-by: Claude <noreply@anthropic.com>
2025-12-03 10:50:28 -05:00
J. Nick Koston
9d6c81ec23 [hlk_fm22x] Fix Action::play method signatures (#12192) 2025-12-03 10:50:28 -05:00
Clyde Stubbs
73fa9230e6 [helpers] Add conversion from FixedVector to std::vector (#12179) 2025-12-03 10:50:28 -05:00
J. Nick Koston
48caff13c9 [espnow] Initialize LwIP stack when running without WiFi component (#12169) 2025-12-03 10:50:28 -05:00
J. Nick Koston
71bb94524e [usb_uart] Wake main loop immediately when USB data arrives (#12148) 2025-12-03 10:50:28 -05:00
Clyde Stubbs
a3199792c6 [build] Don't clear pio cache unless requested (#11966) 2025-12-03 10:50:28 -05:00
Jonathan Swoboda
50c1720c16 Merge pull request #12149 from esphome/bump-2025.11.2
2025.11.2
2025-11-27 18:19:05 -05:00
Jonathan Swoboda
4115dd7222 Bump version to 2025.11.2 2025-11-27 17:23:28 -05:00
J. Nick Koston
d5e2543751 [scheduler] Fix use-after-move crash in heap operations (#12124) 2025-11-27 17:23:28 -05:00
Clyde Stubbs
b4b34aee13 [wifi] Restore blocking setup until connected for RP2040 (#12142) 2025-11-27 17:23:28 -05:00
Jonathan Swoboda
6645994700 [esp32] Fix hosted update when there is no wifi (#12123) 2025-11-27 17:23:28 -05:00
Clyde Stubbs
ae140f52e3 [lvgl] Fix position of errors in widget config (#12111)
Co-authored-by: J. Nick Koston <nick@koston.org>
2025-11-27 17:23:28 -05:00
Clyde Stubbs
46ae6d35a2 [lvgl] Allow multiple widgets per grid cell (#12091) 2025-11-27 17:23:27 -05:00
J. Nick Koston
278f12fb99 [script] Fix script.wait hanging when triggered from on_boot (#12102) 2025-11-27 17:23:27 -05:00
Jonathan Swoboda
acdcd56395 [esp32] Fix platformio flash size print (#12099)
Co-authored-by: J. Nick Koston <nick+github@koston.org>
2025-11-27 17:23:27 -05:00
Edward Firmo
9289fc36f7 [nextion] Do not set alternative baud rate when not specified or <= 0 (#12097) 2025-11-27 17:23:27 -05:00
30 changed files with 456 additions and 49 deletions

View File

@@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome
# could be handy for archiving the generated documentation or if some version
# control system is used.
PROJECT_NUMBER = 2025.11.1
PROJECT_NUMBER = 2025.11.3
# 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

View File

@@ -25,7 +25,8 @@ void ADE7953::setup() {
this->ade_write_8(PGA_V_8, pga_v_);
this->ade_write_8(PGA_IA_8, pga_ia_);
this->ade_write_8(PGA_IB_8, pga_ib_);
this->ade_write_32(AVGAIN_32, vgain_);
this->ade_write_32(AVGAIN_32, avgain_);
this->ade_write_32(BVGAIN_32, bvgain_);
this->ade_write_32(AIGAIN_32, aigain_);
this->ade_write_32(BIGAIN_32, bigain_);
this->ade_write_32(AWGAIN_32, awgain_);
@@ -34,7 +35,8 @@ void ADE7953::setup() {
this->ade_read_8(PGA_V_8, &pga_v_);
this->ade_read_8(PGA_IA_8, &pga_ia_);
this->ade_read_8(PGA_IB_8, &pga_ib_);
this->ade_read_32(AVGAIN_32, &vgain_);
this->ade_read_32(AVGAIN_32, &avgain_);
this->ade_read_32(BVGAIN_32, &bvgain_);
this->ade_read_32(AIGAIN_32, &aigain_);
this->ade_read_32(BIGAIN_32, &bigain_);
this->ade_read_32(AWGAIN_32, &awgain_);
@@ -63,13 +65,14 @@ void ADE7953::dump_config() {
" PGA_V_8: 0x%X\n"
" PGA_IA_8: 0x%X\n"
" PGA_IB_8: 0x%X\n"
" VGAIN_32: 0x%08jX\n"
" AVGAIN_32: 0x%08jX\n"
" BVGAIN_32: 0x%08jX\n"
" AIGAIN_32: 0x%08jX\n"
" BIGAIN_32: 0x%08jX\n"
" AWGAIN_32: 0x%08jX\n"
" BWGAIN_32: 0x%08jX",
this->use_acc_energy_regs_, pga_v_, pga_ia_, pga_ib_, (uintmax_t) vgain_, (uintmax_t) aigain_,
(uintmax_t) bigain_, (uintmax_t) awgain_, (uintmax_t) bwgain_);
this->use_acc_energy_regs_, pga_v_, pga_ia_, pga_ib_, (uintmax_t) avgain_, (uintmax_t) bvgain_,
(uintmax_t) aigain_, (uintmax_t) bigain_, (uintmax_t) awgain_, (uintmax_t) bwgain_);
}
#define ADE_PUBLISH_(name, val, factor) \

View File

@@ -46,7 +46,12 @@ class ADE7953 : public PollingComponent, public sensor::Sensor {
void set_pga_ib(uint8_t pga_ib) { pga_ib_ = pga_ib; }
// Set input gains
void set_vgain(uint32_t vgain) { vgain_ = vgain; }
void set_vgain(uint32_t vgain) {
// Datasheet says: "to avoid discrepancies in other registers,
// if AVGAIN is set then BVGAIN should be set to the same value."
avgain_ = vgain;
bvgain_ = vgain;
}
void set_aigain(uint32_t aigain) { aigain_ = aigain; }
void set_bigain(uint32_t bigain) { bigain_ = bigain; }
void set_awgain(uint32_t awgain) { awgain_ = awgain; }
@@ -100,7 +105,8 @@ class ADE7953 : public PollingComponent, public sensor::Sensor {
uint8_t pga_v_;
uint8_t pga_ia_;
uint8_t pga_ib_;
uint32_t vgain_;
uint32_t avgain_;
uint32_t bvgain_;
uint32_t aigain_;
uint32_t bigain_;
uint32_t awgain_;

View File

@@ -12,10 +12,11 @@ void AnalogThresholdBinarySensor::setup() {
// TRUE state is defined to be when sensor is >= threshold
// so when undefined sensor value initialize to FALSE
if (std::isnan(sensor_value)) {
this->raw_state_ = false;
this->publish_initial_state(false);
} else {
this->publish_initial_state(sensor_value >=
(this->lower_threshold_.value() + this->upper_threshold_.value()) / 2.0f);
this->raw_state_ = sensor_value >= (this->lower_threshold_.value() + this->upper_threshold_.value()) / 2.0f;
this->publish_initial_state(this->raw_state_);
}
}
@@ -25,8 +26,10 @@ void AnalogThresholdBinarySensor::set_sensor(sensor::Sensor *analog_sensor) {
this->sensor_->add_on_state_callback([this](float sensor_value) {
// if there is an invalid sensor reading, ignore the change and keep the current state
if (!std::isnan(sensor_value)) {
this->publish_state(sensor_value >=
(this->state ? this->lower_threshold_.value() : this->upper_threshold_.value()));
// Use raw_state_ for hysteresis logic, not this->state which is post-filter
this->raw_state_ =
sensor_value >= (this->raw_state_ ? this->lower_threshold_.value() : this->upper_threshold_.value());
this->publish_state(this->raw_state_);
}
});
}

View File

@@ -20,6 +20,7 @@ class AnalogThresholdBinarySensor : public Component, public binary_sensor::Bina
sensor::Sensor *sensor_{nullptr};
TemplatableValue<float> upper_threshold_{};
TemplatableValue<float> lower_threshold_{};
bool raw_state_{false}; // Pre-filter state for hysteresis logic
};
} // namespace analog_threshold

View File

@@ -854,6 +854,10 @@ def _configure_lwip_max_sockets(conf: dict) -> None:
async def to_code(config):
cg.add_platformio_option("board", config[CONF_BOARD])
cg.add_platformio_option("board_upload.flash_size", config[CONF_FLASH_SIZE])
cg.add_platformio_option(
"board_upload.maximum_size",
int(config[CONF_FLASH_SIZE].removesuffix("MB")) * 1024 * 1024,
)
cg.set_cpp_standard("gnu++20")
cg.add_build_flag("-DUSE_ESP32")
cg.add_define("ESPHOME_BOARD", config[CONF_BOARD])

View File

@@ -22,6 +22,11 @@ constexpr size_t CHUNK_SIZE = 1500;
void Esp32HostedUpdate::setup() {
this->update_info_.title = "ESP32 Hosted Coprocessor";
// if wifi is not present, connect to the coprocessor
#ifndef USE_WIFI
esp_hosted_connect_to_slave(); // NOLINT
#endif
// get coprocessor version
esp_hosted_coprocessor_fwver_t ver_info;
if (esp_hosted_get_coprocessor_fwversion(&ver_info) == ESP_OK) {

View File

@@ -10,6 +10,7 @@
#include <esp_event.h>
#include <esp_mac.h>
#include <esp_netif.h>
#include <esp_now.h>
#include <esp_random.h>
#include <esp_wifi.h>
@@ -157,6 +158,12 @@ bool ESPNowComponent::is_wifi_enabled() {
}
void ESPNowComponent::setup() {
#ifndef USE_WIFI
// Initialize LwIP stack for wake_loop_threadsafe() socket support
// When WiFi component is present, it handles esp_netif_init()
ESP_ERROR_CHECK(esp_netif_init());
#endif
if (this->enable_on_boot_) {
this->enable_();
} else {

View File

@@ -189,7 +189,7 @@ template<typename... Ts> class EnrollmentAction : public Action<Ts...>, public P
TEMPLATABLE_VALUE(std::string, name)
TEMPLATABLE_VALUE(uint8_t, direction)
void play(Ts... x) override {
void play(const Ts &...x) override {
auto name = this->name_.value(x...);
auto direction = (HlkFm22xFaceDirection) this->direction_.value(x...);
this->parent_->enroll_face(name, direction);
@@ -200,7 +200,7 @@ template<typename... Ts> class DeleteAction : public Action<Ts...>, public Paren
public:
TEMPLATABLE_VALUE(int16_t, face_id)
void play(Ts... x) override {
void play(const Ts &...x) override {
auto face_id = this->face_id_.value(x...);
this->parent_->delete_face(face_id);
}
@@ -208,17 +208,17 @@ template<typename... Ts> class DeleteAction : public Action<Ts...>, public Paren
template<typename... Ts> class DeleteAllAction : public Action<Ts...>, public Parented<HlkFm22xComponent> {
public:
void play(Ts... x) override { this->parent_->delete_all_faces(); }
void play(const Ts &...x) override { this->parent_->delete_all_faces(); }
};
template<typename... Ts> class ScanAction : public Action<Ts...>, public Parented<HlkFm22xComponent> {
public:
void play(Ts... x) override { this->parent_->scan_face(); }
void play(const Ts &...x) override { this->parent_->scan_face(); }
};
template<typename... Ts> class ResetAction : public Action<Ts...>, public Parented<HlkFm22xComponent> {
public:
void play(Ts... x) override { this->parent_->reset(); }
void play(const Ts &...x) override { this->parent_->reset(); }
};
} // namespace esphome::hlk_fm22x

View File

@@ -36,6 +36,8 @@ from .defines import (
)
from .lv_validation import padding, size
CONF_MULTIPLE_WIDGETS_PER_CELL = "multiple_widgets_per_cell"
cell_alignments = LV_CELL_ALIGNMENTS.one_of
grid_alignments = LV_GRID_ALIGNMENTS.one_of
flex_alignments = LV_FLEX_ALIGNMENTS.one_of
@@ -220,6 +222,7 @@ class GridLayout(Layout):
cv.Optional(CONF_GRID_ROW_ALIGN): grid_alignments,
cv.Optional(CONF_PAD_ROW): padding,
cv.Optional(CONF_PAD_COLUMN): padding,
cv.Optional(CONF_MULTIPLE_WIDGETS_PER_CELL, default=False): cv.boolean,
},
{
cv.Optional(CONF_GRID_CELL_ROW_POS): cv.positive_int,
@@ -263,6 +266,7 @@ class GridLayout(Layout):
# should be guaranteed to be a dict at this point
assert isinstance(layout, dict)
assert layout.get(CONF_TYPE).lower() == TYPE_GRID
allow_multiple = layout.get(CONF_MULTIPLE_WIDGETS_PER_CELL, False)
rows = len(layout[CONF_GRID_ROWS])
columns = len(layout[CONF_GRID_COLUMNS])
used_cells = [[None] * columns for _ in range(rows)]
@@ -299,7 +303,10 @@ class GridLayout(Layout):
f"exceeds grid size {rows}x{columns}",
[CONF_WIDGETS, index],
)
if used_cells[row + i][column + j] is not None:
if (
not allow_multiple
and used_cells[row + i][column + j] is not None
):
raise cv.Invalid(
f"Cell span {row + i}/{column + j} already occupied by widget at index {used_cells[row + i][column + j]}",
[CONF_WIDGETS, index],

View File

@@ -1,6 +1,7 @@
from esphome import config_validation as cv
from esphome.automation import Trigger, validate_automation
from esphome.components.time import RealTimeClock
from esphome.config_validation import prepend_path
from esphome.const import (
CONF_ARGS,
CONF_FORMAT,
@@ -422,7 +423,10 @@ def any_widget_schema(extras=None):
def validator(value):
if isinstance(value, dict):
# Convert to list
is_dict = True
value = [{k: v} for k, v in value.items()]
else:
is_dict = False
if not isinstance(value, list):
raise cv.Invalid("Expected a list of widgets")
result = []
@@ -443,7 +447,9 @@ def any_widget_schema(extras=None):
)
# Apply custom validation
value = widget_type.validate(value or {})
result.append({key: container_validator(value)})
path = [key] if is_dict else [index, key]
with prepend_path(path):
result.append({key: container_validator(value)})
return result
return validator

View File

@@ -116,7 +116,7 @@ bool MopekaProCheck::parse_device(const esp32_ble_tracker::ESPBTDevice &device)
// Get temperature of sensor
if (this->temperature_ != nullptr) {
uint8_t temp_in_c = this->parse_temperature_(manu_data.data);
int8_t temp_in_c = this->parse_temperature_(manu_data.data);
this->temperature_->publish_state(temp_in_c);
}
@@ -145,7 +145,7 @@ uint32_t MopekaProCheck::parse_distance_(const std::vector<uint8_t> &message) {
(MOPEKA_LPG_COEF[0] + MOPEKA_LPG_COEF[1] * raw_t + MOPEKA_LPG_COEF[2] * raw_t * raw_t));
}
uint8_t MopekaProCheck::parse_temperature_(const std::vector<uint8_t> &message) { return (message[2] & 0x7F) - 40; }
int8_t MopekaProCheck::parse_temperature_(const std::vector<uint8_t> &message) { return (message[2] & 0x7F) - 40; }
SensorReadQuality MopekaProCheck::parse_read_quality_(const std::vector<uint8_t> &message) {
// Since a 8 bit value is being shifted and truncated to 2 bits all possible values are defined as enumeration

View File

@@ -61,7 +61,7 @@ class MopekaProCheck : public Component, public esp32_ble_tracker::ESPBTDeviceLi
uint8_t parse_battery_level_(const std::vector<uint8_t> &message);
uint32_t parse_distance_(const std::vector<uint8_t> &message);
uint8_t parse_temperature_(const std::vector<uint8_t> &message);
int8_t parse_temperature_(const std::vector<uint8_t> &message);
SensorReadQuality parse_read_quality_(const std::vector<uint8_t> &message);
};

View File

@@ -174,6 +174,9 @@ bool Nextion::upload_tft(uint32_t baud_rate, bool exit_reparse) {
// Check if baud rate is supported
this->original_baud_rate_ = this->parent_->get_baud_rate();
if (baud_rate <= 0) {
baud_rate = this->original_baud_rate_;
}
ESP_LOGD(TAG, "Baud rate: %" PRIu32, baud_rate);
// Define the configuration for the HTTP client

View File

@@ -177,6 +177,9 @@ bool Nextion::upload_tft(uint32_t baud_rate, bool exit_reparse) {
// Check if baud rate is supported
this->original_baud_rate_ = this->parent_->get_baud_rate();
if (baud_rate <= 0) {
baud_rate = this->original_baud_rate_;
}
ESP_LOGD(TAG, "Baud rate: %" PRIu32, baud_rate);
// Define the configuration for the HTTP client

View File

@@ -6,10 +6,13 @@
# in schema.py file in this directory.
from esphome import pins
import esphome.codegen as cg
from esphome.components import libretiny
from esphome.components.libretiny.const import (
COMPONENT_RTL87XX,
FAMILY_RTL8710B,
KEY_COMPONENT_DATA,
KEY_FAMILY,
KEY_LIBRETINY,
LibreTinyComponent,
)
@@ -45,6 +48,11 @@ CONFIG_SCHEMA.prepend_extra(_set_core_data)
async def to_code(config):
# Use FreeRTOS 8.2.3+ for xTaskNotifyGive/ulTaskNotifyTake required by AsyncTCP 3.4.3+
# https://github.com/esphome/esphome/issues/10220
# Only for RTL8710B (ambz) - RTL8720C (ambz2) requires FreeRTOS 10.x
if CORE.data[KEY_LIBRETINY][KEY_FAMILY] == FAMILY_RTL8710B:
cg.add_platformio_option("custom_versions.freertos", "8.2.3")
return await libretiny.component_to_code(config)

View File

@@ -278,7 +278,12 @@ template<class C, typename... Ts> class ScriptWaitAction : public Action<Ts...>,
void setup() override {
// Start with loop disabled - only enable when there's work to do
this->disable_loop();
// IMPORTANT: Only disable if num_running_ is 0, otherwise play_complex() was already
// called before our setup() (e.g., from on_boot trigger at same priority level)
// and we must not undo its enable_loop() call
if (this->num_running_ == 0) {
this->disable_loop();
}
}
void play_complex(const Ts &...x) override {

View File

@@ -1,4 +1,5 @@
import esphome.codegen as cg
from esphome.components import socket
from esphome.components.uart import (
CONF_DATA_BITS,
CONF_PARITY,
@@ -17,7 +18,7 @@ from esphome.const import (
)
from esphome.cpp_types import Component
AUTO_LOAD = ["uart", "usb_host", "bytebuffer"]
AUTO_LOAD = ["uart", "usb_host", "bytebuffer", "socket"]
CODEOWNERS = ["@clydebarrow"]
usb_uart_ns = cg.esphome_ns.namespace("usb_uart")
@@ -116,6 +117,10 @@ CONFIG_SCHEMA = cv.ensure_list(
async def to_code(config):
# Enable wake_loop_threadsafe for low-latency USB data processing
# The USB task queues data events that need immediate processing
socket.require_wake_loop_threadsafe()
for device in config:
var = await register_usb_client(device)
for index, channel in enumerate(device[CONF_CHANNELS]):

View File

@@ -2,6 +2,7 @@
#if defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || defined(USE_ESP32_VARIANT_ESP32P4)
#include "usb_uart.h"
#include "esphome/core/log.h"
#include "esphome/core/application.h"
#include "esphome/components/uart/uart_debugger.h"
#include <cinttypes>
@@ -262,6 +263,11 @@ void USBUartComponent::start_input(USBUartChannel *channel) {
// Push to lock-free queue for main loop processing
// Push always succeeds because pool size == queue size
this->usb_data_queue_.push(chunk);
// Wake main loop immediately to process USB data instead of waiting for select() timeout
#if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE)
App.wake_loop_threadsafe();
#endif
}
// On success, restart input immediately from USB task for performance

View File

@@ -1555,6 +1555,20 @@ void WiFiComponent::retry_connect() {
}
}
#ifdef USE_RP2040
// RP2040's mDNS library (LEAmDNS) relies on LwipIntf::stateUpCB() to restart
// mDNS when the network interface reconnects. However, this callback is disabled
// in the arduino-pico framework. As a workaround, we block component setup until
// WiFi is connected, ensuring mDNS.begin() is called with an active connection.
bool WiFiComponent::can_proceed() {
if (!this->has_sta() || this->state_ == WIFI_COMPONENT_STATE_DISABLED || this->ap_setup_) {
return true;
}
return this->is_connected();
}
#endif
void WiFiComponent::set_reboot_timeout(uint32_t reboot_timeout) { this->reboot_timeout_ = reboot_timeout; }
bool WiFiComponent::is_connected() {
return this->state_ == WIFI_COMPONENT_STATE_STA_CONNECTED &&

View File

@@ -280,6 +280,10 @@ class WiFiComponent : public Component {
void retry_connect();
#ifdef USE_RP2040
bool can_proceed() override;
#endif
void set_reboot_timeout(uint32_t reboot_timeout);
bool is_connected();

View File

@@ -4,7 +4,7 @@ from enum import Enum
from esphome.enum import StrEnum
__version__ = "2025.11.1"
__version__ = "2025.11.3"
ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_"
VALID_SUBSTITUTIONS_CHARACTERS = (

View File

@@ -225,6 +225,9 @@ template<typename T> class FixedVector {
other.reset_();
}
// Allow conversion to std::vector
operator std::vector<T>() const { return {data_, data_ + size_}; }
FixedVector &operator=(FixedVector &&other) noexcept {
if (this != &other) {
// Delete our current data

View File

@@ -359,8 +359,7 @@ void HOT Scheduler::call(uint32_t now) {
std::unique_ptr<SchedulerItem> item;
{
LockGuard guard{this->lock_};
item = std::move(this->items_[0]);
this->pop_raw_();
item = this->pop_raw_locked_();
}
const char *name = item->get_name();
@@ -401,7 +400,7 @@ void HOT Scheduler::call(uint32_t now) {
// Don't run on failed components
if (item->component != nullptr && item->component->is_failed()) {
LockGuard guard{this->lock_};
this->pop_raw_();
this->recycle_item_(this->pop_raw_locked_());
continue;
}
@@ -414,7 +413,7 @@ void HOT Scheduler::call(uint32_t now) {
{
LockGuard guard{this->lock_};
if (is_item_removed_(item.get())) {
this->pop_raw_();
this->recycle_item_(this->pop_raw_locked_());
this->to_remove_--;
continue;
}
@@ -423,7 +422,7 @@ void HOT Scheduler::call(uint32_t now) {
// Single-threaded or multi-threaded with atomics: can check without lock
if (is_item_removed_(item.get())) {
LockGuard guard{this->lock_};
this->pop_raw_();
this->recycle_item_(this->pop_raw_locked_());
this->to_remove_--;
continue;
}
@@ -443,14 +442,14 @@ void HOT Scheduler::call(uint32_t now) {
LockGuard guard{this->lock_};
auto executed_item = std::move(this->items_[0]);
// Only pop after function call, this ensures we were reachable
// during the function call and know if we were cancelled.
this->pop_raw_();
auto executed_item = this->pop_raw_locked_();
if (executed_item->remove) {
// We were removed/cancelled in the function call, stop
// We were removed/cancelled in the function call, recycle and continue
this->to_remove_--;
this->recycle_item_(std::move(executed_item));
continue;
}
@@ -497,7 +496,7 @@ size_t HOT Scheduler::cleanup_() {
return this->items_.size();
// We must hold the lock for the entire cleanup operation because:
// 1. We're modifying items_ (via pop_raw_) which requires exclusive access
// 1. We're modifying items_ (via pop_raw_locked_) which requires exclusive access
// 2. We're decrementing to_remove_ which is also modified by other threads
// (though all modifications are already under lock)
// 3. Other threads read items_ when searching for items to cancel in cancel_item_locked_()
@@ -510,17 +509,18 @@ size_t HOT Scheduler::cleanup_() {
if (!item->remove)
break;
this->to_remove_--;
this->pop_raw_();
this->recycle_item_(this->pop_raw_locked_());
}
return this->items_.size();
}
void HOT Scheduler::pop_raw_() {
std::unique_ptr<Scheduler::SchedulerItem> HOT Scheduler::pop_raw_locked_() {
std::pop_heap(this->items_.begin(), this->items_.end(), SchedulerItem::cmp);
// Instead of destroying, recycle the item
this->recycle_item_(std::move(this->items_.back()));
// Move the item out before popping - this is the item that was at the front of the heap
auto item = std::move(this->items_.back());
this->items_.pop_back();
return item;
}
// Helper to execute a scheduler item

View File

@@ -219,7 +219,9 @@ class Scheduler {
// Returns the number of items remaining after cleanup
// IMPORTANT: This method should only be called from the main thread (loop task).
size_t cleanup_();
void pop_raw_();
// Remove and return the front item from the heap
// IMPORTANT: Caller must hold the scheduler lock before calling this function.
std::unique_ptr<SchedulerItem> pop_raw_locked_();
private:
// Helper to cancel items by name - must be called with lock held

View File

@@ -1,8 +1,12 @@
from collections.abc import Callable
import importlib
import logging
import os
from pathlib import Path
import re
import shutil
import stat
from types import TracebackType
from esphome import loader
from esphome.config import iter_component_configs, iter_components
@@ -121,7 +125,7 @@ def update_storage_json() -> None:
)
else:
_LOGGER.info("Core config or version changed, cleaning build files...")
clean_build()
clean_build(clear_pio_cache=False)
elif storage_should_update_cmake_cache(old, new):
_LOGGER.info("Integrations changed, cleaning cmake cache...")
clean_cmake_cache()
@@ -301,9 +305,24 @@ def clean_cmake_cache():
pioenvs_cmake_path.unlink()
def clean_build():
import shutil
def _rmtree_error_handler(
func: Callable[[str], object],
path: str,
exc_info: tuple[type[BaseException], BaseException, TracebackType | None],
) -> None:
"""Error handler for shutil.rmtree to handle read-only files on Windows.
On Windows, git pack files and other files may be marked read-only,
causing shutil.rmtree to fail with "Access is denied". This handler
removes the read-only flag and retries the deletion.
"""
if os.access(path, os.W_OK):
raise exc_info[1].with_traceback(exc_info[2])
os.chmod(path, stat.S_IWUSR | stat.S_IRUSR)
func(path)
def clean_build(clear_pio_cache: bool = True):
# Allow skipping cache cleaning for integration tests
if os.environ.get("ESPHOME_SKIP_CLEAN_BUILD"):
_LOGGER.warning("Skipping build cleaning (ESPHOME_SKIP_CLEAN_BUILD set)")
@@ -312,16 +331,19 @@ def clean_build():
pioenvs = CORE.relative_pioenvs_path()
if pioenvs.is_dir():
_LOGGER.info("Deleting %s", pioenvs)
shutil.rmtree(pioenvs)
shutil.rmtree(pioenvs, onerror=_rmtree_error_handler)
piolibdeps = CORE.relative_piolibdeps_path()
if piolibdeps.is_dir():
_LOGGER.info("Deleting %s", piolibdeps)
shutil.rmtree(piolibdeps)
shutil.rmtree(piolibdeps, onerror=_rmtree_error_handler)
dependencies_lock = CORE.relative_build_path("dependencies.lock")
if dependencies_lock.is_file():
_LOGGER.info("Deleting %s", dependencies_lock)
dependencies_lock.unlink()
if not clear_pio_cache:
return
# Clean PlatformIO cache to resolve CMake compiler detection issues
# This helps when toolchain paths change or get corrupted
try:
@@ -334,12 +356,10 @@ def clean_build():
cache_dir = Path(config.get("platformio", "cache_dir"))
if cache_dir.is_dir():
_LOGGER.info("Deleting PlatformIO cache %s", cache_dir)
shutil.rmtree(cache_dir)
shutil.rmtree(cache_dir, onerror=_rmtree_error_handler)
def clean_all(configuration: list[str]):
import shutil
data_dirs = []
for config in configuration:
item = Path(config)
@@ -361,7 +381,7 @@ def clean_all(configuration: list[str]):
if item.is_file() and not item.name.endswith(".json"):
item.unlink()
elif item.is_dir() and item.name != "storage":
shutil.rmtree(item)
shutil.rmtree(item, onerror=_rmtree_error_handler)
# Clean PlatformIO project files
try:
@@ -375,7 +395,7 @@ def clean_all(configuration: list[str]):
path = Path(config.get("platformio", pio_dir))
if path.is_dir():
_LOGGER.info("Deleting PlatformIO %s %s", pio_dir, path)
shutil.rmtree(path)
shutil.rmtree(path, onerror=_rmtree_error_handler)
GITIGNORE_CONTENT = """# Gitignore settings for ESPHome

View File

@@ -881,6 +881,7 @@ lvgl:
grid_columns: [40, fr(1), fr(1)]
pad_row: 6px
pad_column: 0
multiple_widgets_per_cell: true
widgets:
- image:
grid_cell_row_pos: 0
@@ -905,6 +906,10 @@ lvgl:
grid_cell_row_pos: 1
grid_cell_column_pos: 0
text: "Grid cell 1/0"
- label:
grid_cell_row_pos: 1
grid_cell_column_pos: 0
text: "Duplicate for 1/0"
- label:
styles: bdr_style
grid_cell_row_pos: 1

View File

@@ -0,0 +1,54 @@
esphome:
name: test-script-wait-on-boot
on_boot:
# Use default priority (600.0) which is same as ScriptWaitAction's setup priority
# This tests the race condition where on_boot runs before ScriptWaitAction::setup()
then:
- logger.log: "=== on_boot: Starting boot sequence ==="
- script.execute: show_start_page
- script.wait: show_start_page
- logger.log: "=== on_boot: First script completed, starting second ==="
- script.execute: flip_thru_pages
- script.wait: flip_thru_pages
- logger.log: "=== on_boot: All boot scripts completed successfully ==="
host:
api:
actions:
# Manual trigger for additional testing
- action: test_script_wait
then:
- logger.log: "=== Manual test: Starting ==="
- script.execute: show_start_page
- script.wait: show_start_page
- logger.log: "=== Manual test: First script completed ==="
- script.execute: flip_thru_pages
- script.wait: flip_thru_pages
- logger.log: "=== Manual test: All completed ==="
logger:
level: DEBUG
script:
# First script - simulates display initialization
- id: show_start_page
mode: single
then:
- logger.log: "show_start_page: Starting"
- delay: 100ms
- logger.log: "show_start_page: After delay 1"
- delay: 100ms
- logger.log: "show_start_page: Completed"
# Second script - simulates page flip sequence
- id: flip_thru_pages
mode: single
then:
- logger.log: "flip_thru_pages: Starting"
- delay: 50ms
- logger.log: "flip_thru_pages: Page 1"
- delay: 50ms
- logger.log: "flip_thru_pages: Page 2"
- delay: 50ms
- logger.log: "flip_thru_pages: Completed"

View File

@@ -0,0 +1,130 @@
"""Integration test for script.wait during on_boot (issue #12043).
This test verifies that script.wait works correctly when triggered from on_boot.
The issue was that ScriptWaitAction::setup() unconditionally disabled the loop,
even if play_complex() had already been called (from an on_boot trigger at the
same priority level) and enabled it.
The race condition occurs because:
1. on_boot's default priority is 600.0 (setup_priority::DATA)
2. ScriptWaitAction's default setup priority is also DATA (600.0)
3. When they have the same priority, if on_boot runs first and triggers a script,
ScriptWaitAction::play_complex() enables the loop
4. Then ScriptWaitAction::setup() runs and unconditionally disables the loop
5. The wait never completes because the loop is disabled
The fix adds a conditional check (like WaitUntilAction has) to only disable the
loop in setup() if num_running_ is 0.
"""
from __future__ import annotations
import asyncio
import re
import pytest
from .types import APIClientConnectedFactory, RunCompiledFunction
@pytest.mark.asyncio
async def test_script_wait_on_boot(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test that script.wait works correctly when triggered from on_boot.
This reproduces issue #12043 where script.wait would hang forever when
triggered from on_boot due to a race condition in ScriptWaitAction::setup().
"""
test_complete = asyncio.Event()
# Track progress through the boot sequence
boot_started = False
first_script_started = False
first_script_completed = False
first_wait_returned = False
second_script_started = False
second_script_completed = False
all_completed = False
# Patterns for boot sequence logs
boot_start_pattern = re.compile(r"on_boot: Starting boot sequence")
show_start_pattern = re.compile(r"show_start_page: Starting")
show_complete_pattern = re.compile(r"show_start_page: Completed")
first_wait_pattern = re.compile(r"on_boot: First script completed")
flip_start_pattern = re.compile(r"flip_thru_pages: Starting")
flip_complete_pattern = re.compile(r"flip_thru_pages: Completed")
all_complete_pattern = re.compile(r"on_boot: All boot scripts completed")
def check_output(line: str) -> None:
"""Check log output for boot sequence progress."""
nonlocal boot_started, first_script_started, first_script_completed
nonlocal first_wait_returned, second_script_started, second_script_completed
nonlocal all_completed
if boot_start_pattern.search(line):
boot_started = True
elif show_start_pattern.search(line):
first_script_started = True
elif show_complete_pattern.search(line):
first_script_completed = True
elif first_wait_pattern.search(line):
first_wait_returned = True
elif flip_start_pattern.search(line):
second_script_started = True
elif flip_complete_pattern.search(line):
second_script_completed = True
elif all_complete_pattern.search(line):
all_completed = True
test_complete.set()
async with (
run_compiled(yaml_config, line_callback=check_output),
api_client_connected() as client,
):
# Verify device info
device_info = await client.device_info()
assert device_info is not None
assert device_info.name == "test-script-wait-on-boot"
# Wait for on_boot sequence to complete
# The boot sequence should complete automatically
# Timeout is generous to allow for delays in the scripts
try:
await asyncio.wait_for(test_complete.wait(), timeout=5.0)
except TimeoutError:
# Build a detailed error message showing where the boot sequence got stuck
progress = []
if boot_started:
progress.append("boot started")
if first_script_started:
progress.append("show_start_page started")
if first_script_completed:
progress.append("show_start_page completed")
if first_wait_returned:
progress.append("first script.wait returned")
if second_script_started:
progress.append("flip_thru_pages started")
if second_script_completed:
progress.append("flip_thru_pages completed")
if not first_wait_returned and first_script_completed:
pytest.fail(
f"Test timed out - script.wait hung after show_start_page completed! "
f"This is the issue #12043 bug. Progress: {', '.join(progress)}"
)
else:
pytest.fail(
f"Test timed out. Progress: {', '.join(progress) if progress else 'none'}"
)
# Verify the complete boot sequence executed in order
assert boot_started, "on_boot did not start"
assert first_script_started, "show_start_page did not start"
assert first_script_completed, "show_start_page did not complete"
assert first_wait_returned, "First script.wait did not return"
assert second_script_started, "flip_thru_pages did not start"
assert second_script_completed, "flip_thru_pages did not complete"
assert all_completed, "Boot sequence did not complete"

View File

@@ -1,7 +1,9 @@
"""Test writer module functionality."""
from collections.abc import Callable
import os
from pathlib import Path
import stat
from typing import Any
from unittest.mock import MagicMock, patch
@@ -15,6 +17,7 @@ from esphome.writer import (
CPP_INCLUDE_BEGIN,
CPP_INCLUDE_END,
GITIGNORE_CONTENT,
clean_all,
clean_build,
clean_cmake_cache,
storage_should_clean,
@@ -1062,3 +1065,103 @@ def test_clean_all_preserves_json_files(
# Verify logging mentions cleaning
assert "Cleaning" in caplog.text
assert str(build_dir) in caplog.text
@patch("esphome.writer.CORE")
def test_clean_build_handles_readonly_files(
mock_core: MagicMock,
tmp_path: Path,
) -> None:
"""Test clean_build handles read-only files (e.g., git pack files on Windows)."""
# Create directory structure with read-only files
pioenvs_dir = tmp_path / ".pioenvs"
pioenvs_dir.mkdir()
git_dir = pioenvs_dir / ".git" / "objects" / "pack"
git_dir.mkdir(parents=True)
# Create a read-only file (simulating git pack files on Windows)
readonly_file = git_dir / "pack-abc123.pack"
readonly_file.write_text("pack data")
os.chmod(readonly_file, stat.S_IRUSR) # Read-only
# Setup mocks
mock_core.relative_pioenvs_path.return_value = pioenvs_dir
mock_core.relative_piolibdeps_path.return_value = tmp_path / ".piolibdeps"
mock_core.relative_build_path.return_value = tmp_path / "dependencies.lock"
# Verify file is read-only
assert not os.access(readonly_file, os.W_OK)
# Call the function - should not crash
clean_build()
# Verify directory was removed despite read-only files
assert not pioenvs_dir.exists()
@patch("esphome.writer.CORE")
def test_clean_all_handles_readonly_files(
mock_core: MagicMock,
tmp_path: Path,
) -> None:
"""Test clean_all handles read-only files."""
# Create config directory
config_dir = tmp_path / "config"
config_dir.mkdir()
build_dir = config_dir / ".esphome"
build_dir.mkdir()
# Create a subdirectory with read-only files
subdir = build_dir / "subdir"
subdir.mkdir()
readonly_file = subdir / "readonly.txt"
readonly_file.write_text("content")
os.chmod(readonly_file, stat.S_IRUSR) # Read-only
# Verify file is read-only
assert not os.access(readonly_file, os.W_OK)
# Call the function - should not crash
clean_all([str(config_dir)])
# Verify directory was removed despite read-only files
assert not subdir.exists()
assert build_dir.exists() # .esphome dir itself is preserved
@patch("esphome.writer.CORE")
def test_clean_build_reraises_for_other_errors(
mock_core: MagicMock,
tmp_path: Path,
) -> None:
"""Test clean_build re-raises errors that are not read-only permission issues."""
# Create directory structure with a read-only subdirectory
# This prevents file deletion and triggers the error handler
pioenvs_dir = tmp_path / ".pioenvs"
pioenvs_dir.mkdir()
subdir = pioenvs_dir / "subdir"
subdir.mkdir()
test_file = subdir / "test.txt"
test_file.write_text("content")
# Make subdir read-only so files inside can't be deleted
os.chmod(subdir, stat.S_IRUSR | stat.S_IXUSR)
# Setup mocks
mock_core.relative_pioenvs_path.return_value = pioenvs_dir
mock_core.relative_piolibdeps_path.return_value = tmp_path / ".piolibdeps"
mock_core.relative_build_path.return_value = tmp_path / "dependencies.lock"
try:
# Mock os.access in writer module to return True (writable)
# This simulates a case where the error is NOT due to read-only permissions
# so the error handler should re-raise instead of trying to fix permissions
with (
patch("esphome.writer.os.access", return_value=True),
pytest.raises(PermissionError),
):
clean_build()
finally:
# Cleanup - restore write permission so tmp_path cleanup works
os.chmod(subdir, stat.S_IRWXU)