[lvgl] Use contextvars to improve lvgl scheduling (#19242)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Clyde Stubbs
2026-09-30 04:54:38 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent e111f1130c
commit 5baad05926
36 changed files with 726 additions and 317 deletions
@@ -0,0 +1,28 @@
esphome:
name: test-keyboard-before-textarea
esp32:
board: lolin_c3_mini
spi:
mosi_pin:
number: GPIO2
ignore_strapping_warning: true
clk_pin: GPIO1
display:
- platform: mipi_spi
data_rate: 20MHz
model: st7735
cs_pin:
number: GPIO8
ignore_strapping_warning: true
dc_pin: GPIO3
lvgl:
widgets:
- keyboard:
id: kb
textarea: ta
- textarea:
id: ta
@@ -0,0 +1,34 @@
esphome:
name: test-keyboard-update-textarea
on_boot:
- lvgl.keyboard.update:
id: kb
textarea: ta2
esp32:
board: lolin_c3_mini
spi:
mosi_pin:
number: GPIO2
ignore_strapping_warning: true
clk_pin: GPIO1
display:
- platform: mipi_spi
data_rate: 20MHz
model: st7735
cs_pin:
number: GPIO8
ignore_strapping_warning: true
dc_pin: GPIO3
lvgl:
widgets:
- keyboard:
id: kb
textarea: ta1
- textarea:
id: ta1
- textarea:
id: ta2
@@ -0,0 +1,35 @@
esphome:
name: test-multi-instance-theme
host:
mac_address: "62:23:45:AF:B3:DD"
display:
- platform: snapshot
id: sdl0
dimensions:
width: 100
height: 100
- platform: snapshot
id: sdl1
dimensions:
width: 100
height: 100
lvgl:
- id: lvgl_0
displays: sdl0
widgets:
- label:
id: label_0
text: "First"
- id: lvgl_1
displays: sdl1
theme:
label:
bg_color: 0xFF0000
widgets:
- label:
id: label_1
text: "Second"
@@ -0,0 +1,47 @@
"""Regression test: a keyboard: declared before its textarea: sibling must
still get attached to it, and only after both widgets exist.
attach_textareas() runs as a deferred pass, after every widget (across every
LVGL instance) is created, so it must emit the attach call after the
keyboard's own creation statement, not inline during widget creation.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from esphome.__main__ import generate_cpp_contents
from esphome.config import read_config
from esphome.core import CORE
@pytest.fixture(scope="module")
def main_cpp(request: pytest.FixtureRequest) -> str:
config_path = (
Path(request.fspath).parent / "config" / "keyboard_before_textarea_test.yaml"
)
original_path = CORE.config_path
try:
CORE.config_path = config_path
CORE.config = read_config({})
generate_cpp_contents(CORE.config)
return CORE.cpp_main_section
finally:
CORE.config_path = original_path
CORE.reset()
def test_keyboard_and_textarea_are_created(main_cpp: str) -> None:
assert "lv_keyboard_create(" in main_cpp
assert "lv_textarea_create(" in main_cpp
def test_attach_call_runs_after_keyboard_and_textarea_are_created(
main_cpp: str,
) -> None:
attach_index = main_cpp.find("lv_keyboard_set_textarea(kb->obj, ta);")
assert attach_index != -1, "keyboard was never attached to its textarea"
assert attach_index > main_cpp.find("lv_keyboard_create(")
assert attach_index > main_cpp.find("lv_textarea_create(")
@@ -0,0 +1,33 @@
"""Regression test: lvgl.keyboard.update must be able to change which
textarea a keyboard is attached to.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from esphome.__main__ import generate_cpp_contents
from esphome.config import read_config
from esphome.core import CORE
@pytest.fixture(scope="module")
def main_cpp(request: pytest.FixtureRequest) -> str:
config_path = (
Path(request.fspath).parent / "config" / "keyboard_update_textarea_test.yaml"
)
original_path = CORE.config_path
try:
CORE.config_path = config_path
CORE.config = read_config({})
generate_cpp_contents(CORE.config)
return CORE.cpp_main_section
finally:
CORE.config_path = original_path
CORE.reset()
def test_keyboard_update_changes_textarea(main_cpp: str) -> None:
assert "lv_keyboard_set_textarea(kb->obj, ta2);" in main_cpp
+4 -4
View File
@@ -286,7 +286,7 @@ def test_add_text_with_index_moves_before_firing_on_add(main_cpp: str) -> None:
def test_add_button_with_checkable_flag(main_cpp: str) -> None:
assert "lv_obj_t *dyn_button_VAR_ = lv_btn_create(test_list);" in main_cpp
assert "lv_obj_t *dyn_button_VAR_ = lv_button_create(test_list);" in main_cpp
assert (
"lv_obj_add_flag(dyn_button_VAR_, (lv_obj_flag_t)(LV_OBJ_FLAG_CHECKABLE));"
in main_cpp
@@ -310,7 +310,7 @@ def test_add_nested_hierarchy_with_compound_child(main_cpp: str) -> None:
assert (
"lv_obj_t *dyn_label_1_VAR_ = lv_label_create(dyn_obj_VAR_);\n"
" lv_obj_add_style(dyn_label_1_VAR_, _lv_theme_style_label_main_default, "
"(lv_state_t)(LV_PART_MAIN));\n"
"LV_PART_MAIN);\n"
' lv_label_set_text(dyn_label_1_VAR_, "Nested");'
) in main_cpp
@@ -321,7 +321,7 @@ def test_add_applies_theme_styles_to_dynamic_widget(main_cpp: str) -> None:
"""
assert (
"lv_obj_add_style(dyn_label_1_VAR_, _lv_theme_style_label_main_default, "
"(lv_state_t)(LV_PART_MAIN));"
"LV_PART_MAIN);"
) in main_cpp
assert "LvDropdownType *dyn_dropdown_1_VAR_ = new LvDropdownType();" in main_cpp
assert "lv_dropdown_create(dyn_obj_VAR_)" in main_cpp
@@ -343,7 +343,7 @@ def test_add_nested_same_type_child_does_not_shadow_parent(main_cpp: str) -> Non
assert (
"lv_obj_t *dyn_label_2_VAR_ = lv_label_create(dyn_obj_1_VAR_);\n"
" lv_obj_add_style(dyn_label_2_VAR_, _lv_theme_style_label_main_default, "
"(lv_state_t)(LV_PART_MAIN));\n"
"LV_PART_MAIN);\n"
' lv_label_set_text(dyn_label_2_VAR_, "Grandchild");'
) in main_cpp
@@ -1,16 +1,4 @@
"""Regression test: on_add:/on_remove: containing an lvgl action must not deadlock.
ListType.to_code() used to build the on_add/on_remove automations directly, during
widget creation. Every lvgl action's to_code awaits wait_for_widgets(), which only
resolves once *all* widgets - including the list itself - have finished being
created. Building an automation containing an lvgl action from inside that same
widget-creation walk therefore could never complete: codegen deadlocked with
"Circular dependency detected!". Fixed by deferring the actual build_automation()
call to finish_list_triggers(), run after set_widgets_completed(True) - and,
critically, before generate_triggers(), which is what processes other widgets'
on_click etc. automations that might reference this list (e.g. via lvgl.list.add),
and which therefore need the list's own on_add/on_remove triggers to already exist.
"""
"""Regression test: on_add:/on_remove: containing an lvgl action must not deadlock."""
from __future__ import annotations
@@ -0,0 +1,44 @@
"""Regression test: a widget's theme style must be attached regardless of
which LVGL instance declares theme: and which instance's widgets are built
first.
theme_to_code() runs once per LVGL instance, interleaved with that instance's
own add_widgets(). A widget built by an earlier instance, before any instance
has declared theming for its type, must still pick up a later instance's
theme: declaration for that type - including on the later instance's own
widgets, which get_widget_theme_styles()'s memoisation can poison too.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from esphome.__main__ import generate_cpp_contents
from esphome.config import read_config
from esphome.core import CORE
@pytest.fixture(scope="module")
def main_cpp(request: pytest.FixtureRequest) -> str:
config_path = (
Path(request.fspath).parent / "config" / "multi_instance_theme_test.yaml"
)
original_path = CORE.config_path
try:
CORE.config_path = config_path
CORE.config = read_config({})
generate_cpp_contents(CORE.config)
return CORE.cpp_main_section
finally:
CORE.config_path = original_path
CORE.reset()
def test_earlier_instance_widget_is_themed(main_cpp: str) -> None:
assert "lv_obj_add_style(label_0, _lv_theme_style_label_main_default," in main_cpp
def test_later_instance_widget_is_themed(main_cpp: str) -> None:
assert "lv_obj_add_style(label_1, _lv_theme_style_label_main_default," in main_cpp
+18
View File
@@ -42,6 +42,13 @@ script:
label:
text: row
esphome:
on_boot:
then:
- lvgl.theme.update:
obj:
border_width: 2
lvgl:
id: lvgl_id
rotation: 90
@@ -1351,6 +1358,17 @@ lvgl:
id: checkbox_id
text: Checkbox
align: bottom_right
- list:
id: list_a
on_add:
- lvgl.list.add:
id: list_b # fires list_b's on_add
label: {text: x}
- list:
id: list_b
on_add:
- logger.log: added
- list:
id: test_list_id
align: top_right
@@ -0,0 +1,56 @@
"""Regression test: action_to_code(): Confirms two
actions whose own processing each suspends mid-lambda, waiting for an ID, don't
corrupt each other's LambdaContext when interleaved.
"""
from __future__ import annotations
import pytest
from esphome.automation import ACTION_REGISTRY
import esphome.codegen as cg
from esphome.components.lvgl.automation import action_to_code
from esphome.components.lvgl.lvcode import lv_add
from esphome.core import CORE, ID
from esphome.cpp_generator import RawExpression, TemplateArguments
@pytest.mark.asyncio
async def test_action_to_code_survives_interleaved_suspended_contexts(
setup_core,
) -> None:
later_id_a = ID("later_var_a", False, cg.int_)
later_id_b = ID("later_var_b", False, cg.int_)
action_type = ACTION_REGISTRY["lvgl.list.add"].type_id
async def action_a(_widget) -> None:
value = await cg.get_variable(later_id_a)
lv_add(RawExpression(f"action_a_marker({value})"))
async def action_b(_widget) -> None:
value = await cg.get_variable(later_id_b)
lv_add(RawExpression(f"action_b_marker({value})"))
async def run_action_a() -> None:
action_id = ID("test_action_a", is_declaration=True, type=action_type)
await action_to_code([None], action_a, action_id, TemplateArguments(), [])
async def run_action_b() -> None:
action_id = ID("test_action_b", is_declaration=True, type=action_type)
await action_to_code([None], action_b, action_id, TemplateArguments(), [])
async def define_later_ids() -> None:
# Both actions are still suspended, mid-LambdaContext, when this runs -
# resolving both at once lets each resume while the other's context is
# still open, rather than one finishing before the other starts.
cg.new_variable(later_id_a, RawExpression("1"))
cg.new_variable(later_id_b, RawExpression("2"))
CORE.add_job(run_action_a)
CORE.add_job(run_action_b)
CORE.add_job(define_later_ids)
CORE.flush_tasks()
text = "\n".join(str(s) for s in CORE.main_statements)
assert "action_a_marker(later_var_a)" in text
assert "action_b_marker(later_var_b)" in text
@@ -0,0 +1,33 @@
"""Tests for get_part_state_selector()'s three branches."""
from __future__ import annotations
from esphome.components.lvgl.defines import get_part_state_selector
def test_default_state_returns_bare_part() -> None:
assert str(get_part_state_selector("main", "default")) == "LV_PART_MAIN"
assert str(get_part_state_selector("knob", "default")) == "LV_PART_KNOB"
def test_main_part_with_non_default_state_returns_bare_state() -> None:
assert str(get_part_state_selector("main", "pressed")) == "LV_STATE_PRESSED"
def test_non_main_part_with_non_default_state_combines_both() -> None:
assert str(get_part_state_selector("knob", "pressed")) == (
"(static_cast<lv_style_selector_t>(LV_STATE_PRESSED) | "
"static_cast<lv_style_selector_t>(LV_PART_KNOB))"
)
def test_accepts_already_prefixed_part_and_state() -> None:
assert (
str(get_part_state_selector("LV_PART_MAIN", "LV_STATE_DEFAULT"))
== "LV_PART_MAIN"
)
assert (
str(get_part_state_selector("LV_PART_KNOB", "LV_STATE_PRESSED"))
== "(static_cast<lv_style_selector_t>(LV_STATE_PRESSED) | "
"static_cast<lv_style_selector_t>(LV_PART_KNOB))"
)
@@ -0,0 +1,129 @@
"""Regression test: an lvgl.list.add action must fire a list's on_add trigger
even if it reaches _fire_on_add() before finish_list_triggers() has built that
list's Trigger Pvariable.
ListType.on_create() only records on_add/on_remove configs (via
_declare_list_triggers()) - finish_list_triggers() is what actually builds the
Trigger Pvariables from them. An lvgl.list.add action for a list can be
scheduled as part of a different component's own to_code() coroutine, entirely
independent of lvgl's own, so it can reach _fire_on_add() before
finish_list_triggers() has run for that list. _fire_on_add()/_fire_on_remove()
resolve each trigger via cg.get_variable(), which blocks until
finish_list_triggers() builds it - regardless of which of the two jobs the
scheduler happens to run first.
This is reproduced deterministically here (no reliance on incidental component
priority/scheduling) by scheduling the action's job before finish_list_triggers()
on ESPHome's own coroutine scheduler: without cg.get_variable()'s wait, the
action job would run to completion first and observe the trigger as not yet
built.
"""
from __future__ import annotations
from unittest.mock import patch
import pytest
from esphome.automation import ACTION_REGISTRY
from esphome.components.lvgl.lvcode import LvContext
from esphome.components.lvgl.schemas import container_schema
from esphome.components.lvgl.widgets import Widget, widget_to_code
from esphome.components.lvgl.widgets.lv_list import (
CONF_ON_ADD,
_get_list_triggers,
finish_list_triggers,
list_spec,
)
from esphome.const import CONF_AUTOMATION_ID, CONF_THEN, CONF_TRIGGER_ID, CONF_TYPE_ID
from esphome.core import CORE, ID
from esphome.cpp_generator import MockObj, TemplateArguments
from esphome.yaml_util import make_data_base
def _statements() -> list[str]:
return [str(s) for s in CORE.main_statements]
@pytest.mark.asyncio
async def test_list_add_action_running_before_finish_list_triggers_still_fires_on_add(
setup_core,
) -> None:
config = container_schema(list_spec)(
{
"id": "test_list",
CONF_ON_ADD: [{"lambda": make_data_base("return;")}],
}
)
# Auto-generated IDs (trigger/automation/action) are normally resolved to
# unique names by esphome's full config pass before code generation; do
# that by hand here since this test only exercises the widget/trigger
# codegen slice in isolation.
automation_conf = config[CONF_ON_ADD][0]
automation_conf[CONF_TRIGGER_ID].resolve([])
automation_conf[CONF_AUTOMATION_ID].resolve([])
automation_conf[CONF_THEN][0][CONF_TYPE_ID].resolve([])
parent = MockObj("parent_obj")
async with LvContext():
await widget_to_code(config, list_spec, parent)
# Schedule the lvgl.list.add action's job before finish_list_triggers()'s -
# mirroring an action that lives in a different component's automation than
# lvgl's own to_code(), which can reach this action before lvgl gets to build
# this list's on_add/on_remove triggers.
entry = ACTION_REGISTRY["lvgl.list.add"]
add_config = entry.schema({"id": "test_list", "label": {"text": "row"}})
action_id = ID("test_list_add_action", is_declaration=True, type=entry.type_id)
async def run_add_action() -> None:
async with LvContext():
await entry.coroutine_fun(add_config, action_id, TemplateArguments(), [])
CORE.add_job(run_add_action)
CORE.add_job(finish_list_triggers)
CORE.flush_tasks()
statements = _statements()
assert any("->trigger(" in s for s in statements), (
"on_add did not fire: the lvgl.list.add action ran before "
"finish_list_triggers() built the list's on_add trigger, and "
"_fire_on_add() didn't wait for it"
)
@pytest.mark.asyncio
async def test_on_add_recorded_before_widget_registered(setup_core) -> None:
"""Widget.create() is what makes a list visible to get_widgets(), so an
action interleaved with its creation could resolve get_widgets() and reach
_fire_on_add() right after Widget.create() runs. Its on_add config must
already be recorded by then - ListType.on_create() (called before
Widget.create()) is what guarantees that, not ListType.to_code() (called
after).
"""
config = container_schema(list_spec)(
{
"id": "test_list",
CONF_ON_ADD: [{"lambda": make_data_base("return;")}],
}
)
automation_conf = config[CONF_ON_ADD][0]
automation_conf[CONF_TRIGGER_ID].resolve([])
automation_conf[CONF_AUTOMATION_ID].resolve([])
automation_conf[CONF_THEN][0][CONF_TYPE_ID].resolve([])
seen_on_add_counts = []
real_create = Widget.create
def spy_create(name, var, wtype, config=None):
seen_on_add_counts.append(len(_get_list_triggers(name).on_add))
return real_create(name, var, wtype, config)
parent = MockObj("parent_obj")
with patch.object(Widget, "create", side_effect=spy_create):
async with LvContext():
await widget_to_code(config, list_spec, parent)
assert seen_on_add_counts == [1], (
"on_add wasn't recorded yet when Widget.create() registered the list"
)
@@ -5,7 +5,6 @@ from __future__ import annotations
import pytest
from esphome.automation import ACTION_REGISTRY
from esphome.components.lvgl.defines import set_widgets_completed
from esphome.components.lvgl.lvcode import LvContext
from esphome.components.lvgl.schemas import container_schema
from esphome.components.lvgl.trigger import generate_triggers
@@ -151,7 +150,6 @@ async def test_selected_cell_omitted_entirely_when_not_configured(
@pytest.mark.asyncio
async def test_cell_update_action_writes_only_the_given_fields(setup_core) -> None:
await _create_table({"id": "table_update", "rows": [["a", "b"], ["c", "d"]]})
set_widgets_completed(True)
# Only inspect statements emitted by the action below, not by creation.
before = len(_statements())
@@ -194,7 +192,6 @@ async def test_on_value_registers_a_value_changed_event_callback(setup_core) ->
parent = MockObj("parent_obj")
async with LvContext():
await widget_to_code(config, table_spec, parent)
set_widgets_completed(True)
await generate_triggers()
statements = _statements()
@@ -0,0 +1,44 @@
"""Regression test: an lvgl.theme.update action must still apply its style
change even if it runs before theme_to_code() has built that style.
theme_update_to_code() reads get_theme_widget_map() synchronously and raises
cv.Invalid if the requested style isn't there yet - relying on theme_to_code()
(which materialises a style for every requested widget/part/state combo) to
have always already run. That's guaranteed when the action lives inside the
lvgl: block's own automations (same to_code() job, sequential), but not when
it's scheduled as part of a different component's own to_code() job - e.g.
tests/components/lvgl/lvgl-package.yaml's `esphome: on_boot:` case, which this
test reproduces at the scheduler level.
"""
from __future__ import annotations
import pytest
from esphome.automation import ACTION_REGISTRY
from esphome.components.lvgl.schemas import theme_update_schema
from esphome.components.lvgl.styles import theme_to_code
from esphome.core import CORE, ID
from esphome.cpp_generator import TemplateArguments
@pytest.mark.asyncio
async def test_theme_update_before_theme_to_code_still_applies(setup_core) -> None:
add_config = theme_update_schema({"obj": {"border_width": 2}})
entry = ACTION_REGISTRY["lvgl.theme.update"]
action_id = ID("test_theme_update_action", is_declaration=True, type=entry.type_id)
async def run_update_action() -> None:
await entry.coroutine_fun(add_config, action_id, TemplateArguments(), [])
# Scheduled before theme_to_code()'s job - mirrors the action being reached
# from a different component's own to_code() job than lvgl's.
CORE.add_job(run_update_action)
CORE.add_job(theme_to_code, {})
CORE.flush_tasks()
statements = [str(s) for s in CORE.main_statements]
assert any("style_set_border_width" in s for s in statements), (
"theme.update's border_width change was never applied"
)