2
Doxyfile
2
Doxyfile
@@ -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
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
54
tests/integration/fixtures/script_wait_on_boot.yaml
Normal file
54
tests/integration/fixtures/script_wait_on_boot.yaml
Normal 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"
|
||||
130
tests/integration/test_script_wait_on_boot.py
Normal file
130
tests/integration/test_script_wait_on_boot.py
Normal 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"
|
||||
Reference in New Issue
Block a user