Merge upstream/dev into integration

This commit is contained in:
J. Nick Koston
2025-11-25 21:21:03 -06:00
39 changed files with 409 additions and 82 deletions
@@ -29,7 +29,7 @@ def test_binary_sensor_sets_mandatory_fields(generate_main):
)
# Then
assert 'bs_1->set_name("test bs1");' in main_cpp
assert 'bs_1->set_name_and_object_id("test bs1", "test_bs1");' in main_cpp
assert "bs_1->set_pin(" in main_cpp
+1 -1
View File
@@ -26,7 +26,7 @@ def test_button_sets_mandatory_fields(generate_main):
main_cpp = generate_main("tests/component_tests/button/test_button.yaml")
# Then
assert 'wol_1->set_name("wol_test_1");' in main_cpp
assert 'wol_1->set_name_and_object_id("wol_test_1", "wol_test_1");' in main_cpp
assert "wol_2->set_macaddr(18, 52, 86, 120, 144, 171);" in main_cpp
+1 -1
View File
@@ -27,7 +27,7 @@ def test_text_sets_mandatory_fields(generate_main):
main_cpp = generate_main("tests/component_tests/text/test_text.yaml")
# Then
assert 'it_1->set_name("test 1 text");' in main_cpp
assert 'it_1->set_name_and_object_id("test 1 text", "test_1_text");' in main_cpp
def test_text_config_value_internal_set(generate_main):
@@ -25,9 +25,18 @@ def test_text_sensor_sets_mandatory_fields(generate_main):
main_cpp = generate_main("tests/component_tests/text_sensor/test_text_sensor.yaml")
# Then
assert 'ts_1->set_name("Template Text Sensor 1");' in main_cpp
assert 'ts_2->set_name("Template Text Sensor 2");' in main_cpp
assert 'ts_3->set_name("Template Text Sensor 3");' in main_cpp
assert (
'ts_1->set_name_and_object_id("Template Text Sensor 1", "template_text_sensor_1");'
in main_cpp
)
assert (
'ts_2->set_name_and_object_id("Template Text Sensor 2", "template_text_sensor_2");'
in main_cpp
)
assert (
'ts_3->set_name_and_object_id("Template Text Sensor 3", "template_text_sensor_3");'
in main_cpp
)
def test_text_sensor_config_value_internal_set(generate_main):
+24
View File
@@ -537,6 +537,9 @@ lvgl:
- tileview:
id: tileview_id
scrollbar_mode: active
scroll_dir: all
scroll_elastic: true
scroll_momentum: true
on_value:
then:
- if:
@@ -546,7 +549,10 @@ lvgl:
- logger.log: "tile 1 is now showing"
tiles:
- id: tile_1
scroll_snap_y: center
scroll_snap_x: start
layout: vertical
pad_all: 6px
row: 0
column: 0
dir: ALL
@@ -781,6 +787,18 @@ lvgl:
arc_color: 0xFFFF00
focused:
arc_color: 0x808080
on_click:
then:
- lvgl.arc.update:
id: lv_arc_1
value: !lambda return (int)((float)rand() / RAND_MAX * 100);
min_value: !lambda return (int)((float)rand() / RAND_MAX * 100);
max_value: !lambda return (int)((float)rand() / RAND_MAX * 100);
start_angle: !lambda return (int)((float)rand() / RAND_MAX * 100);
end_angle: !lambda return (int)((float)rand() / RAND_MAX * 100);
rotation: !lambda return (int)((float)rand() / RAND_MAX * 100);
change_rate: !lambda return (uint)((float)rand() / RAND_MAX * 100);
mode: NORMAL
- bar:
id: bar_id
align: top_mid
@@ -881,6 +899,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 +924,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
@@ -1027,6 +1050,7 @@ lvgl:
opa: 0%
- id: page3
layout: Horizontal
pad_all: 6px
widgets:
- keyboard:
id: lv_keyboard
@@ -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"
@@ -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"
+10 -3
View File
@@ -27,8 +27,13 @@ from esphome.helpers import sanitize, snake_case
from .common import load_config_from_fixture
# Pre-compiled regex pattern for extracting object IDs from expressions
# Pre-compiled regex patterns for extracting object IDs from expressions
# Matches both old format: .set_object_id("obj_id")
# and new format: .set_name_and_object_id("name", "obj_id")
OBJECT_ID_PATTERN = re.compile(r'\.set_object_id\(["\'](.*?)["\']\)')
COMBINED_PATTERN = re.compile(
r'\.set_name_and_object_id\(["\'].*?["\']\s*,\s*["\'](.*?)["\']\)'
)
FIXTURES_DIR = Path(__file__).parent.parent / "fixtures" / "core" / "entity_helpers"
@@ -273,8 +278,10 @@ def setup_test_environment() -> Generator[list[str], None, None]:
def extract_object_id_from_expressions(expressions: list[str]) -> str | None:
"""Extract the object ID that was set from the generated expressions."""
for expr in expressions:
# Look for set_object_id calls with regex to handle various formats
# Matches: var.set_object_id("temperature_2") or var.set_object_id('temperature_2')
# First try new combined format: .set_name_and_object_id("name", "obj_id")
if match := COMBINED_PATTERN.search(expr):
return match.group(1)
# Fall back to old format: .set_object_id("obj_id")
if match := OBJECT_ID_PATTERN.search(expr):
return match.group(1)
return None