[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,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"
)