Merge pull request #12149 from esphome/bump-2025.11.2

2025.11.2
This commit is contained in:
Jonathan Swoboda
2025-11-27 18:19:05 -05:00
committed by GitHub
16 changed files with 261 additions and 19 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.2
# 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

@@ -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

@@ -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

@@ -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

@@ -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

@@ -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.2"
ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_"
VALID_SUBSTITUTIONS_CHARACTERS = (

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

@@ -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"