[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
+8 -10
View File
@@ -61,7 +61,6 @@ from .defines import (
get_focused_widgets,
get_lv_images_used,
get_refreshed_widgets,
set_widgets_completed,
)
from .encoders import (
ENCODERS_CONFIG,
@@ -108,6 +107,7 @@ from .widgets import (
get_screen_active,
set_obj_properties,
)
from .widgets.keyboard import attach_textareas
# Import only what we actually use directly in this file
from .widgets.msgbox import MSGBOX_SCHEMA, msgboxes_to_code
@@ -396,6 +396,11 @@ async def to_code(configs):
default_group = get_default_group(config_0)
df.get_options()[df.CONF_DEBUG_OUTLINE] = config_0[df.CONF_DEBUG_OUTLINE]
# Create theme lambdas before any widgets.
async with LvContext():
for config in configs:
await theme_to_code(config)
for config in configs:
frac = config[CONF_BUFFER_SIZE]
if frac >= 0.75:
@@ -448,7 +453,6 @@ async def to_code(configs):
await touchscreens_to_code(lv_component, config)
await encoders_to_code(lv_component, config, default_group)
await keypads_to_code(lv_component, config, default_group)
await theme_to_code(config)
await gradients_to_code(config)
await styles_to_code(config)
await set_obj_properties(lv_scr_act, config)
@@ -459,20 +463,14 @@ async def to_code(configs):
await msgboxes_to_code(lv_component, config)
await animations_to_code(config.get(CONF_ANIMATIONS, []))
# Mark all widgets as completed so awaiters of ``wait_for_widgets`` proceed.
set_widgets_completed(True)
async with LvContext():
# Local import: lv_list imports meter, which imports obj_spec/set_obj_properties
# from this module's own namespace - a top-level import here would be circular.
# Local import to avoid circularity
from .widgets.lv_list import finish_list_triggers
# Must run before generate_triggers(): that's what actually processes other
# widgets' on_click etc. automations, which can include lvgl.list.add/remove/
# clear actions that fire a list's on_add/on_remove triggers - those need to
# already exist by then, not still be pending.
await finish_list_triggers()
await generate_triggers()
await generate_align_tos(configs[0])
await attach_textareas()
for config in configs:
lv_component = await cg.get_variable(config[CONF_ID])
await add_animation_triggers(config.get(CONF_ANIMATIONS, []))
+3 -16
View File
@@ -78,7 +78,6 @@ from .widgets import (
get_screen_active,
get_widgets,
set_obj_properties,
wait_for_widgets,
)
# Widgets that are used in a focused/refreshed action are tracked in
@@ -127,15 +126,7 @@ async def action_to_code(
action_id,
template_arg,
args,
config=None,
):
# Ensure all required ids have been processed, so our LambdaContext doesn't get context-switched.
if config:
for lamb in config.values():
if isinstance(lamb, Lambda):
for id_ in lamb.requires_ids:
await get_variable(id_)
await wait_for_widgets()
async with LambdaContext(parameters=args, where=action_id) as context:
for widget in widgets:
await action(widget)
@@ -145,7 +136,7 @@ async def action_to_code(
async def update_to_code(config, action_id, template_arg, args):
async def do_update(widget: Widget):
await set_obj_properties(widget, config)
await widget.type.to_code(widget, config)
await widget.type.update_to_code(widget, config)
if (
widget.type.w_type.value_property is not None
and widget.type.w_type.value_property in config
@@ -153,9 +144,7 @@ async def update_to_code(config, action_id, template_arg, args):
lv_obj.send_event(widget.obj, UPDATE_EVENT, nullptr)
widgets = await get_widgets(config[CONF_ID])
return await action_to_code(
widgets, do_update, action_id, template_arg, args, config
)
return await action_to_code(widgets, do_update, action_id, template_arg, args)
@automation.register_condition(
@@ -507,9 +496,7 @@ async def obj_update_to_code(config, action_id, template_arg, args):
await set_obj_properties(widget, config)
widgets = await get_widgets(config[CONF_ID])
return await action_to_code(
widgets, do_update, action_id, template_arg, args, config
)
return await action_to_code(widgets, do_update, action_id, template_arg, args)
def validate_refresh_config(config):
@@ -9,7 +9,7 @@ from esphome.const import CONF_STATE
from ..defines import CONF_WIDGET, LV_OBJ_FLAG, LvConstant
from ..lvcode import EVENT_ARG, UPDATE_EVENT, LambdaContext, LvContext, lvgl_static
from ..types import LV_EVENT, LV_STATE, lv_pseudo_button_t
from ..widgets import Widget, get_widgets, wait_for_widgets
from ..widgets import Widget, get_widgets
STATE_PRESSED = "PRESSED"
STATE_CHECKED = "CHECKED"
@@ -33,7 +33,6 @@ async def to_code(config):
widget = widget[0]
assert isinstance(widget, Widget)
state = await BS_STATE.process(config[CONF_STATE])
await wait_for_widgets()
is_pressed = str(state) == str(LV_STATE.PRESSED)
test_expr = widget.is_pressed() if is_pressed else widget.is_checked()
async with LambdaContext(EVENT_ARG) as test_ctx:
+31 -21
View File
@@ -20,21 +20,22 @@ DOMAIN = "lvgl"
KEY_COLOR_FORMATS = "color_formats"
KEY_ESPHOME_FONTS_USED = "esphome_fonts_used"
KEY_FOCUSED_WIDGETS = "focused_widgets"
KEY_LIST_TRIGGERS = "list_triggers"
KEY_LV_DEFINES = "lv_defines"
KEY_LV_FONTS_USED = "lv_fonts_used"
KEY_LV_IMAGES_USED = "lv_images_used"
KEY_LV_USES = "lv_uses"
KEY_NAMED_STYLES = "named_styles"
KEY_OPTIONS = "options"
KEY_REFRESHED_WIDGETS = "refreshed_widgets"
KEY_REMAPPED_USES = "remapped_uses"
KEY_STYLES_USED = "styles_used"
KEY_THEME_UPDATE_REQUESTS = "theme_update_requests"
KEY_THEME_WIDGET_MAP = "theme_widget_map"
KEY_THEME_STYLES = "theme_styles"
KEY_UPDATED_WIDGETS = "updated_widgets"
KEY_WIDGET_MAP = "widget_map"
KEY_WIDGETS_COMPLETED = "widgets_completed"
KEY_OPTIONS = "options"
KEY_WARNINGS = "warnings"
KEY_WIDGET_MAP = "widget_map"
KEY_WIDGET_THEME_STYLES = "widget_theme_styles"
KEY_DEBUG_OUTLINE_COUNT = "debug_outline_count"
# Colours for the debug outline, in (red, green, blue) order. They are picked to stay
@@ -137,8 +138,14 @@ def get_updated_widgets() -> dict:
return _get_data(KEY_UPDATED_WIDGETS, {})
def get_theme_widget_map() -> dict[str, Any]:
return _get_data(KEY_THEME_WIDGET_MAP, {})
def get_theme_styles() -> dict[str, MockObj]:
"""Get a map of already created theme style names to their corresponding style IDs."""
return _get_data(KEY_THEME_STYLES, {})
def get_widget_theme_style_data() -> dict[str, list[tuple[MockObj, MockObj]]]:
"""Get the map of widget type names to the list of (style variable, part/state name)"""
return _get_data(KEY_WIDGET_THEME_STYLES, {})
def get_theme_update_requests() -> dict[str, dict[tuple[str, str], None]]:
@@ -157,20 +164,6 @@ def get_widget_map() -> dict[str, Any]:
return _get_data(KEY_WIDGET_MAP, {})
def get_widgets_completed() -> bool:
# ``[value]`` rather than the bare value so that we can mutate the
# entry in place; ``CORE.data`` is reset for us between runs.
return _get_data(KEY_WIDGETS_COMPLETED, [False])[0]
def set_widgets_completed(value: bool) -> None:
_get_data(KEY_WIDGETS_COMPLETED, [False])[0] = value
def is_widget_completed(name: ID) -> bool:
return name in get_widget_map()
def get_focused_widgets() -> set:
return _get_data(KEY_FOCUSED_WIDGETS, set())
@@ -179,6 +172,10 @@ def get_refreshed_widgets() -> set:
return _get_data(KEY_REFRESHED_WIDGETS, set())
def get_list_triggers() -> dict:
return _get_data(KEY_LIST_TRIGGERS, {})
def add_define(macro: str, value="1"):
lv_defines = get_defines()
value = str(value)
@@ -869,7 +866,7 @@ LV_SCALE_MODE = LvConstant(
DEFAULT_ESPHOME_FONT = "esphome_lv_default_font"
def join_enums(enums, prefix=""):
def join_enums(enums: tuple[str], prefix: str = "") -> MockObj:
enums = list(enums)
enums.sort()
# If a prefix is provided, prepend each constant with the prefix, and assume that all the constants are within the
@@ -879,6 +876,19 @@ def join_enums(enums, prefix=""):
return literal("|".join(f"(int){e.upper()}" for e in enums))
def get_part_state_selector(part: str, state: str) -> MockObj:
"""Combine a part and state into a single selector value, e.g. LV_PART_KNOB | LV_STATE_PRESSED."""
state = "LV_STATE_" + state.removeprefix("LV_STATE_").upper()
part = "LV_PART_" + part.removeprefix("LV_PART_").upper()
if state == "LV_STATE_DEFAULT":
return literal(part)
if part == "LV_PART_MAIN":
return literal(state)
return MockObj(
StaticCastExpression("lv_style_selector_t", literal(state))
) | MockObj(StaticCastExpression("lv_style_selector_t", literal(part)))
# fmt: off
LV_COLOR_FORMATS = (
"RGB565", "SWAPPED", "RGB565A8", "RGB888", "XRGB8888", "ARGB8888", "PREMULTIPLIED", "L8", "AL88", "A8", "I1",
+1 -2
View File
@@ -7,7 +7,7 @@ from esphome.const import CONF_GAMMA_CORRECT, CONF_OUTPUT_ID
from ..defines import CONF_WIDGET
from ..lvcode import LvContext
from ..types import LvType, lvgl_ns
from ..widgets import get_widgets, wait_for_widgets
from ..widgets import get_widgets
lv_led_t = LvType("lv_led_t")
LVLight = lvgl_ns.class_("LVLight", LightOutput)
@@ -26,6 +26,5 @@ async def to_code(config):
widget = await get_widgets(config, CONF_WIDGET)
widget = widget[0]
await wait_for_widgets()
async with LvContext() as ctx:
ctx.add(var.set_obj(widget.obj))
+25 -8
View File
@@ -1,5 +1,5 @@
import abc
from typing import TYPE_CHECKING
import contextvars
from esphome import codegen as cg
from esphome.config import Config
@@ -65,15 +65,33 @@ class IndentedStatement(Statement):
return result
class CodeContext(abc.ABC):
class _CodeContextMeta(abc.ABCMeta):
"""
Backs `CodeContext.code_context` with a contextvar instead of a plain class
attribute, so the FakeEventLoop's per-task context isolation (see coroutine.py)
keeps interleaved to_code() jobs from clobbering each other's current context.
"""
_context_var: contextvars.ContextVar["CodeContext | None"] = contextvars.ContextVar(
"code_context", default=None
)
@property
def code_context(cls) -> "CodeContext | None":
return _CodeContextMeta._context_var.get()
@code_context.setter
def code_context(cls, value: "CodeContext | None") -> None:
_CodeContextMeta._context_var.set(value)
class CodeContext(abc.ABC, metaclass=_CodeContextMeta):
"""
A class providing a context for code generation. Generated code will be added to the
current context. A new context will stack on the current context, and restore it
when done. Used with the `with` statement.
"""
code_context = None
@abc.abstractmethod
def add(self, expression: Expression | Statement):
pass
@@ -115,6 +133,9 @@ class CodeContext(abc.ABC):
def indented_statement(self, stmt):
return IndentedStatement(stmt, self.indent_level)
def get_automation_parameters(self) -> list[tuple[SafeExpType, str]]:
return []
class MainContext(CodeContext):
"""
@@ -212,10 +233,6 @@ def get_lambda_context_args() -> list[tuple[SafeExpType, str]]:
"""
if CodeContext.code_context is None:
return []
if TYPE_CHECKING:
# CodeContext base class doesn't define get_automation_parameters(),
# but LambdaContext and LvContext (the concrete implementations) do.
assert isinstance(CodeContext.code_context, LambdaContext)
return CodeContext.code_context.get_automation_parameters()
+1 -2
View File
@@ -22,7 +22,7 @@ from ..lvcode import (
)
from ..schemas import TRIGGER_EVENT_MAP, VALUE_TRIGGER_SCHEMA
from ..types import LvNumber, lvgl_ns
from ..widgets import get_widgets, wait_for_widgets
from ..widgets import get_widgets
LVGLNumber = lvgl_ns.class_("LVGLNumber", number.Number, cg.Component)
@@ -47,7 +47,6 @@ async def to_code(config):
trigger = CONF_ON_RELEASE
widget = await get_widgets(config, CONF_WIDGET)
widget = widget[0]
await wait_for_widgets()
async with LambdaContext([], return_type=cg.float_) as value:
value.add(ReturnStatement(widget.get_value()))
async with LambdaContext([(cg.float_, "v")]) as control:
+1 -5
View File
@@ -670,11 +670,7 @@ def theme_update_schema(value: dict) -> dict:
for w_name, style in validated.items():
for part, states in collect_parts(style).items():
for state, props in states.items():
# collect_parts() unconditionally seeds a main/default entry
# even when nothing was set for it (e.g. `{pressed: {...}}`
# alone) -- skip combos with no properties so a request for
# one state doesn't also create an unused, empty main/default
# style that gets attached to every widget of this type.
# Skip states with no properties
if not props:
continue
df.get_theme_update_requests().setdefault(w_name, {})[(part, state)] = (
+1 -2
View File
@@ -5,7 +5,7 @@ from ..defines import CONF_TRIGGER, CONF_WIDGET
from ..lvcode import EVENT_ARG, LambdaContext, LvContext, lv_add, lvgl_static
from ..schemas import TRIGGER_EVENT_MAP, VALUE_TRIGGER_SCHEMA
from ..types import LvNumber
from ..widgets import Widget, get_widgets, wait_for_widgets
from ..widgets import Widget, get_widgets
CONFIG_SCHEMA = sensor_schema(Sensor).extend(
{
@@ -20,7 +20,6 @@ async def to_code(config):
widget = await get_widgets(config, CONF_WIDGET)
widget = widget[0]
assert isinstance(widget, Widget)
await wait_for_widgets()
async with LambdaContext(EVENT_ARG) as lamb:
lv_add(sensor.publish_state(widget.get_value()))
async with LvContext():
+53 -41
View File
@@ -1,17 +1,22 @@
from esphome import automation
import esphome.codegen as cg
import esphome.config_validation as cv
from esphome.const import CONF_ID
from esphome.const import CONF_DEFAULT, CONF_ID
from esphome.core import ID
from esphome.cpp_generator import MockObj
from .defines import (
CONF_STYLE_DEFINITIONS,
CONF_THEME,
PARTS,
STATES,
LValidator,
add_lv_use,
get_part_state_selector,
get_styles_used,
get_theme_styles,
get_theme_update_requests,
get_theme_widget_map,
get_widget_theme_style_data,
literal,
)
from .lvcode import LambdaContext, lv
@@ -23,7 +28,7 @@ from .schemas import (
theme_update_schema,
)
from .types import ObjUpdateAction, lv_style_t
from .widgets import collect_parts, wait_for_widgets
from .widgets import collect_parts
def has_style_props(config) -> bool:
@@ -89,7 +94,6 @@ async def styles_to_code(config):
synchronous=True,
)
async def style_update_to_code(config, action_id, template_arg, args):
await wait_for_widgets()
style = await cg.get_variable(config[CONF_ID])
async with LambdaContext(parameters=args, where=action_id) as context:
await style_set(style, config)
@@ -101,41 +105,64 @@ async def style_update_to_code(config, action_id, template_arg, args):
return cg.new_Pvariable(action_id, template_arg, await context.get_lambda())
def _get_theme_style_name(w_name: str, part: str, state: str) -> str:
return f"_lv_theme_style_{w_name}_{part}_{state}"
def get_widget_theme_styles(w_name: str) -> list[tuple[MockObj, MockObj]]:
"""Return a list of (style variable, part/state name) for all theme styles used by the given widget type."""
widget_styles = get_widget_theme_style_data()
if w_name in widget_styles:
return widget_styles[w_name]
theme_styles = get_theme_styles()
style_list = []
for part in PARTS:
for state in STATES + (CONF_DEFAULT,):
style_name = _get_theme_style_name(w_name, part, state)
if style_name in theme_styles:
style_list.append(
(theme_styles[style_name], get_part_state_selector(part, state))
)
widget_styles[w_name] = style_list
return style_list
async def theme_to_code(config):
"""
Convert theme to C++ code. May be called multiple times for different LVGL instances.
A style is created for each (widget type, part, state) combo declared in the `theme:` section of the config,
or requested by a `theme.update` action.
If a style is requested but not declared, it is created as an empty placeholder.
:param config:
:return:
"""
theme = config.get(CONF_THEME) or {}
requests = get_theme_update_requests()
# Iterate in WIDGET_TYPES' (deterministic, registration-order) sequence rather
# than a set -- a set of strings/tuples iterates in an order that depends on
# per-process hash randomization, which would otherwise churn the order hidden
# style variables are declared in main.cpp between builds of the same config.
widget_names = [
w_name for w_name in WIDGET_TYPES if w_name in theme or w_name in requests
]
if not widget_names:
return
add_lv_use(CONF_THEME)
theme_map = get_theme_widget_map()
style_map = get_theme_styles()
for w_name in widget_names:
declared_parts = collect_parts(theme[w_name]) if w_name in theme else {}
parts = {part: dict(states) for part, states in declared_parts.items()}
for part, state in requests.get(w_name, {}):
parts.setdefault(part, {}).setdefault(state, {})
widget_styles = theme_map.setdefault(w_name, {})
for part, states in parts.items():
part_styles = widget_styles.setdefault(part, {})
declared_states = declared_parts.get(part, {})
for state, props in states.items():
if state not in part_styles:
part_styles[state] = await create_style(
"_lv_theme_style_" + w_name + "_" + part + "_" + state, props
)
style_name = _get_theme_style_name(w_name, part, state)
if style_name not in style_map:
style_map[style_name] = await create_style(style_name, props)
elif state in declared_states:
# A `theme.update` request for this combo (possibly from
# another LVGL instance) already created the style as an
# empty placeholder before this instance's real `theme:`
# declaration was reached -- apply the real values now
# instead of silently leaving it empty.
await style_set(part_styles[state], props)
await style_set(style_map[style_name], props)
@automation.register_action(
@@ -144,40 +171,25 @@ async def theme_to_code(config):
theme_update_schema,
synchronous=True,
)
async def theme_update_to_code(config, action_id, template_arg, args):
await wait_for_widgets()
theme_map = get_theme_widget_map()
# Invariant this relies on: theme_update_schema() records every (widget
# type, part, state) combo this action targets as a request during config
# validation (which completes for the whole config tree before any
# to_code runs), and theme_to_code() -- which runs for every LVGL
# instance before any action's own to_code -- materialises a style for
# each recorded request. If that handshake is ever broken by a future
# change, fail with a diagnosable message rather than a bare KeyError.
to_update = []
async def theme_update_to_code(config, action_id, template_arg, args) -> MockObj:
# The theme_update_schema records the requested (widget type, part, state) combos in a global dict so that
# theme_to_code() can create the corresponding styles variables. Here we await get_variable(), which will
# context switch if required so theme_to_code() can run and create the style variable.
to_update: list[tuple] = []
for w_name, style in config.items():
for part, states in collect_parts(style).items():
for state, props in states.items():
# collect_parts() unconditionally seeds an (empty) main/default
# entry even when this action didn't target it -- skip it, both
# because there's nothing to update and because
# theme_update_schema no longer pre-creates a placeholder style
# for combos with no properties.
# Skip states with no properties to set.
if not props:
continue
style_var = theme_map.get(w_name, {}).get(part, {}).get(state)
if style_var is None:
raise cv.Invalid(
f"No theme style exists for '{w_name}' {part}/{state}. "
"This is an internal error -- please report it."
)
style_var = await cg.get_variable(
ID(_get_theme_style_name(w_name, part, state))
)
to_update.append((style_var, props))
async with LambdaContext(parameters=args, where=action_id) as context:
for style_var, props in to_update:
await style_set(style_var, props)
# Refresh and redraw every widget using this style -- otherwise the
# updated properties would sit unused until something else happens
# to invalidate the affected widgets.
# Trigger a redraw for affected widgets.
lv.obj_report_style_change(style_var)
return cg.new_Pvariable(action_id, template_arg, await context.get_lambda())
+1 -2
View File
@@ -15,7 +15,7 @@ from ..lvcode import (
lvgl_static,
)
from ..types import LV_EVENT, LV_STATE, lv_pseudo_button_t, lvgl_ns
from ..widgets import get_widgets, wait_for_widgets
from ..widgets import get_widgets
LVGLSwitch = lvgl_ns.class_("LVGLSwitch", Switch, Component)
CONFIG_SCHEMA = switch_schema(LVGLSwitch).extend(
@@ -28,7 +28,6 @@ CONFIG_SCHEMA = switch_schema(LVGLSwitch).extend(
async def to_code(config):
widget = await get_widgets(config, CONF_WIDGET)
widget = widget[0]
await wait_for_widgets()
switch_id = MockObj(config[CONF_ID], "->")
v = literal("v")
async with LambdaContext([(cg.bool_, "v")]) as control:
+1 -2
View File
@@ -14,7 +14,7 @@ from ..lvcode import (
lvgl_static,
)
from ..types import LV_EVENT, LvText, lvgl_ns
from ..widgets import get_widgets, wait_for_widgets
from ..widgets import get_widgets
LVGLText = lvgl_ns.class_("LVGLText", text.Text)
@@ -29,7 +29,6 @@ async def to_code(config):
textvar = await new_text(config)
widget = await get_widgets(config, CONF_WIDGET)
widget = widget[0]
await wait_for_widgets()
async with LambdaContext([(cg.std_string, "text_value")]) as control:
await widget.set_property("text", "text_value.c_str()")
lv_obj.send_event(widget.obj, UPDATE_EVENT, cg.nullptr)
@@ -8,7 +8,7 @@ import esphome.config_validation as cv
from ..defines import CONF_WIDGET
from ..lvcode import EVENT_ARG, UPDATE_EVENT, LambdaContext, LvContext, lvgl_static
from ..types import LV_EVENT, LvText
from ..widgets import get_widgets, wait_for_widgets
from ..widgets import get_widgets
CONFIG_SCHEMA = text_sensor_schema(TextSensor).extend(
{
@@ -21,7 +21,6 @@ async def to_code(config):
sensor = await new_text_sensor(config)
widget = await get_widgets(config, CONF_WIDGET)
widget = widget[0]
await wait_for_widgets()
async with LambdaContext(EVENT_ARG) as pressed_ctx:
pressed_ctx.add(sensor.publish_state(widget.get_value()))
async with LvContext() as ctx:
+17 -43
View File
@@ -4,7 +4,7 @@ from typing import Any
from esphome import codegen as cg, config_validation as cv
from esphome.automation import register_action
from esphome.config_validation import Invalid, Schema
from esphome.config_validation import Schema
from esphome.const import (
CONF_DEFAULT,
CONF_GROUP,
@@ -44,10 +44,9 @@ from ..defines import (
LValidator,
add_lv_use,
get_options,
get_part_state_selector,
get_styles_used,
get_theme_widget_map,
get_widget_map,
get_widgets_completed,
join_enums,
literal,
next_debug_outline_color,
@@ -199,13 +198,21 @@ class WidgetType:
await self.to_code(w, config)
return w
async def to_code(self, w: "Widget", config: dict):
async def to_code(self, w: "Widget", config: dict) -> None:
"""
Update a widget, also called when creating
Generate code for widget properties and actions.
:param config:
:return:
"""
async def update_to_code(self, w: "Widget", config: dict) -> None:
"""
Update a widget. Defaults to calling to_code, but can be overridden
:param w: The widget to update
:param config: The configuration for the update
"""
await self.to_code(w, config)
async def obj_creator(self, parent: MockObj, config: dict):
"""
Create an instance of the widget type
@@ -261,17 +268,10 @@ class WidgetType:
def apply_theme_styles(w: "Widget") -> None:
"""Apply the current theme's styles for this widget's type"""
for part, states in get_theme_widget_map().get(w.type.name, {}).items():
part = "LV_PART_" + part.upper()
for state, style in states.items():
state = "LV_STATE_" + state.upper()
if state == "LV_STATE_DEFAULT":
lv_state = literal(part)
elif part == "LV_PART_MAIN":
lv_state = literal(state)
else:
lv_state = join_enums((state, part))
w.add_style(style, lv_state)
from ..styles import get_widget_theme_styles
for style, lv_state in get_widget_theme_styles(w.type.name):
w.add_style(style, lv_state)
def apply_debug_outline(w: "Widget") -> None:
@@ -342,8 +342,6 @@ class Widget:
return lv_obj.remove_flag(self.obj, literal(flag))
def add_style(self, style_id, state=LV_STATE.DEFAULT):
if "|" in state:
state = f"(lv_state_t)({state})"
lv_obj.add_style(self.obj, MockObj(style_id), literal(state))
async def set_property(
@@ -476,10 +474,6 @@ def get_widget_generator(wid):
while True:
if obj := widget_map.get(wid):
return obj
if get_widgets_completed():
raise Invalid(
f"Widget {wid} not found, yet all widgets should be defined by now"
)
yield
@@ -489,19 +483,6 @@ async def get_widget_(wid):
return await FakeAwaitable(get_widget_generator(wid))
def widgets_wait_generator():
while True:
if get_widgets_completed():
return
yield
async def wait_for_widgets():
if get_widgets_completed():
return
await FakeAwaitable(widgets_wait_generator())
async def get_widgets(config: dict | list, id: str = CONF_ID) -> list[Widget]:
if not config:
return []
@@ -634,15 +615,8 @@ async def set_obj_properties(w: Widget, config):
_set_layout_options(w, layout, base_name)
parts = collect_parts(config)
for part, states in parts.items():
part = "LV_PART_" + part.upper()
for state, props in states.items():
state = "LV_STATE_" + state.upper()
if state == "LV_STATE_DEFAULT":
lv_state = literal(part)
elif part == "LV_PART_MAIN":
lv_state = literal(state)
else:
lv_state = join_enums((state, part))
lv_state = get_part_state_selector(part, state)
for style_id in props.get(CONF_STYLES, ()):
w.add_style(style_id, lv_state)
for prop, value in {
+1 -6
View File
@@ -15,9 +15,7 @@ lv_button_t = LvBoolean("lv_button_t")
class ButtonType(WidgetType):
def __init__(self):
super().__init__(
CONF_BUTTON, lv_button_t, (CONF_MAIN,), schema=TEXT_SCHEMA, lv_name="btn"
)
super().__init__(CONF_BUTTON, lv_button_t, (CONF_MAIN,), schema=TEXT_SCHEMA)
def validate(self, value):
if CONF_TEXT in value:
@@ -26,9 +24,6 @@ class ButtonType(WidgetType):
add_lv_use("label")
return value
def get_uses(self):
return ("btn",)
async def on_create(self, var: MockObj, config: dict):
if CONF_TEXT in config:
lv.label_create(var)
@@ -188,7 +188,6 @@ class ButtonMatrixType(WidgetType):
(CONF_MAIN, CONF_ITEMS),
BUTTONMATRIX_SCHEMA,
{},
lv_name="buttonmatrix",
)
async def to_code(self, w: Widget, config):
@@ -272,5 +271,5 @@ async def button_update_to_code(config, action_id, template_arg, args):
)
return await action_to_code(
widgets, do_button_update, action_id, template_arg, args, config
widgets, do_button_update, action_id, template_arg, args
)
+3 -7
View File
@@ -141,7 +141,7 @@ async def canvas_fill(config, action_id, template_arg, args):
async def do_fill(w: Widget):
lv.canvas_fill_bg(w.obj, color, opa)
return await action_to_code(widget, do_fill, action_id, template_arg, args, config)
return await action_to_code(widget, do_fill, action_id, template_arg, args)
@automation.register_action(
@@ -176,9 +176,7 @@ async def canvas_set_pixel(config, action_id, template_arg, args):
x, y = point
lv.canvas_set_px(w.obj, x, y, color, opa)
return await action_to_code(
widget, do_set_pixels, action_id, template_arg, args, config
)
return await action_to_code(widget, do_set_pixels, action_id, template_arg, args)
DRAW_SCHEMA = {
@@ -214,9 +212,7 @@ async def draw_to_code(config, dsc_type, props, do_draw, action_id, template_arg
await do_draw(addr(layer), x, y, dsc)
lv.canvas_finish_layer(w.obj, addr(layer))
return await action_to_code(
widget, action_func, action_id, template_arg, args, config
)
return await action_to_code(widget, action_func, action_id, template_arg, args)
RECT_PROPS = {
+22 -28
View File
@@ -1,17 +1,9 @@
from esphome.components.key_provider import KeyProvider
import esphome.config_validation as cv
from esphome.const import CONF_ITEMS, CONF_MODE
from esphome.core import CORE
from esphome.cpp_types import std_string
from .. import LvContext
from ..defines import (
CONF_MAIN,
KEYBOARD_MODES,
add_lv_use,
is_widget_completed,
literal,
)
from ..defines import CONF_MAIN, KEYBOARD_MODES, get_widget_map, literal
from ..types import LvCompound, LvType
from . import Widget, WidgetType, get_widgets
from .buttonmatrix import CONF_BUTTONMATRIX
@@ -50,30 +42,32 @@ class KeyboardType(WidgetType):
)
def get_uses(self):
return CONF_KEYBOARD, CONF_TEXTAREA, CONF_BUTTONMATRIX, CONF_LABEL
return (
CONF_KEYBOARD,
CONF_TEXTAREA,
CONF_BUTTONMATRIX,
CONF_LABEL,
"KEY_LISTENER",
)
async def to_code(self, w: Widget, config: dict):
add_lv_use("KEY_LISTENER")
if mode := config.get(CONF_MODE):
await w.set_property(CONF_MODE, await KEYBOARD_MODES.process(mode))
if textarea := config.get(CONF_TEXTAREA):
if not is_widget_completed(textarea):
# Can only happen for an initial config, where the keyboard is configured before the
# textarea, so it's ok to always emit into the global context
async def add_textarea():
async with LvContext():
await w.set_property(
CONF_TEXTAREA,
(await get_widgets(config, CONF_TEXTAREA))[0].obj,
)
CORE.add_job(add_textarea)
else:
# Handles updates in automations, and properly ordered initial config. Code is generated
# into the enclosing context (main or lambda)
await w.set_property(
CONF_TEXTAREA, (await get_widgets(config, CONF_TEXTAREA))[0].obj
)
async def update_to_code(self, w: "Widget", config: dict) -> None:
await self.to_code(w, config)
if config.get(CONF_TEXTAREA):
await w.set_property(
CONF_TEXTAREA, (await get_widgets(config, CONF_TEXTAREA))[0].obj
)
async def attach_textareas():
for w in get_widget_map().values():
if w.type == keyboard_spec and w.config.get(CONF_TEXTAREA):
await w.set_property(
CONF_TEXTAREA, (await get_widgets(w.config, CONF_TEXTAREA))[0].obj
)
keyboard_spec = KeyboardType()
+45 -81
View File
@@ -1,4 +1,3 @@
from collections.abc import Generator
from dataclasses import dataclass, field
from typing import Any
@@ -15,8 +14,6 @@ from esphome.const import (
CONF_TEXT,
CONF_TRIGGER_ID,
)
from esphome.core import CORE
from esphome.coroutine import FakeAwaitable
from esphome.cpp_generator import MockObj
from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor
@@ -31,6 +28,7 @@ from ..defines import (
SWIPE_TRIGGERS,
TYPE_FLEX,
add_lv_use,
get_list_triggers,
literal,
)
from ..lv_validation import lv_int, lv_text, padding
@@ -65,6 +63,7 @@ from . import (
)
from .buttonmatrix import CONF_BUTTONMATRIX
from .canvas import CONF_CANVAS
from .keyboard import CONF_KEYBOARD
from .label import CONF_LABEL
from .meter import CONF_METER
from .tabview import CONF_TABVIEW
@@ -75,8 +74,6 @@ CONF_WIDGET = "widget"
CONF_ON_ADD = "on_add"
CONF_ON_REMOVE = "on_remove"
DOMAIN = "lvgl_list"
lv_list_t = LvType("lv_list_t")
@@ -87,76 +84,49 @@ class ListTriggers:
def _get_list_triggers(list_id) -> ListTriggers:
"""
Trigger Pvariables built for a given list's `on_add`/`on_remove` config, indexed by the
list's own ID.
"""
triggers_by_list = CORE.data.setdefault(DOMAIN, {})
return triggers_by_list.setdefault(list_id, ListTriggers())
"""Every on_add/on_remove automation config declared for a list, indexed by the list's own ID."""
return get_list_triggers().get(list_id, ListTriggers())
def _get_pending_list_triggers(list_id) -> ListTriggers:
"""
Same shape as _get_list_triggers(), but holding raw on_add/on_remove automation
configs, not yet built.
"""
pending_by_list = CORE.data.setdefault(DOMAIN + "_pending", {})
return pending_by_list.setdefault(list_id, ListTriggers())
def _list_triggers_completed_flag() -> list[bool]:
return CORE.data.setdefault(DOMAIN + "_completed", [False])
def _list_triggers_completed_generator() -> Generator[None, None, None]:
while True:
if _list_triggers_completed_flag()[0]:
return
yield
async def _wait_list_triggers_completed() -> None:
"""Waits until finish_list_triggers() has built every list's on_add/on_remove automations."""
if _list_triggers_completed_flag()[0]:
return
await FakeAwaitable(_list_triggers_completed_generator())
def _declare_list_triggers(list_id) -> ListTriggers:
"""Get-or-create a list's trigger record, for ListType.on_create() to populate."""
return get_list_triggers().setdefault(list_id, ListTriggers())
async def finish_list_triggers() -> None:
"""
Builds every list's on_add/on_remove automations, collected by ListType.to_code()
instead of being built there directly. Must run after set_widgets_completed(True).
Builds every list's on_add/on_remove automations, collected by ListType.on_create()
instead of being built there directly.
"""
for list_id, pending in CORE.data.get(DOMAIN + "_pending", {}).items():
triggers = _get_list_triggers(list_id)
for conf in pending.on_add:
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID])
await automation.build_automation(trigger, [(cg.int_, "list_index")], conf)
triggers.on_add.append(trigger)
for conf in pending.on_remove:
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID])
await automation.build_automation(trigger, [(cg.int_, "list_index")], conf)
triggers.on_remove.append(trigger)
_list_triggers_completed_flag()[0] = True
# Avoid circular references by creating the trigger Pvariables first
built_triggers = [
(cg.new_Pvariable(conf[CONF_TRIGGER_ID]), conf)
for triggers in get_list_triggers().values()
for conf in triggers.on_add + triggers.on_remove
]
for trigger, conf in built_triggers:
await automation.build_automation(trigger, [(cg.int_, "list_index")], conf)
def _fire_index_triggers(triggers: list, index) -> None:
for trigger in triggers:
async def _fire_index_triggers(confs: list, index) -> None:
for conf in confs:
# finish_list_triggers() may not have built this trigger yet if it runs as
# part of a different component's own to_code() job than this one -
# get_variable() blocks until it does, regardless of scheduling order.
trigger = await cg.get_variable(conf[CONF_TRIGGER_ID])
lv_add(trigger.trigger(index))
async def _fire_on_add(list_id, list_obj, entry_obj) -> None:
await _wait_list_triggers_completed()
triggers = _get_list_triggers(list_id).on_add
if not triggers:
confs = _get_list_triggers(list_id).on_add
if not confs:
return
index = cg.RawExpression(f"lvgl::lv_list_get_row_index({list_obj}, {entry_obj})")
_fire_index_triggers(triggers, index)
await _fire_index_triggers(confs, index)
async def _fire_on_remove(list_id, index) -> None:
await _wait_list_triggers_completed()
_fire_index_triggers(_get_list_triggers(list_id).on_remove, index)
await _fire_index_triggers(_get_list_triggers(list_id).on_remove, index)
LIST_SCHEMA = cv.Schema(
@@ -200,14 +170,15 @@ class ListType(WidgetType):
def get_uses(self):
return TYPE_FLEX, CONF_LABEL, CONF_BUTTON
async def to_code(self, w: Widget, config: dict):
async def on_create(self, var: MockObj, config: dict) -> None:
# Runs before Widget.create() registers this list
on_add = config.get(CONF_ON_ADD, ())
on_remove = config.get(CONF_ON_REMOVE, ())
if not on_add and not on_remove:
return
pending = _get_pending_list_triggers(w.config[CONF_ID])
pending.on_add.extend(on_add)
pending.on_remove.extend(on_remove)
triggers = _declare_list_triggers(config[CONF_ID])
triggers.on_add.extend(on_add)
triggers.on_remove.extend(on_remove)
list_spec = ListType()
@@ -228,7 +199,6 @@ LIST_ID_SCHEMA = cv.Schema({cv.Required(CONF_ID): cv.use_id(lv_list_t)})
)
async def list_add_text_to_code(config, action_id, template_arg, args):
widgets = await get_widgets(config)
await _wait_list_triggers_completed()
async def do_add_text(w: Widget):
text = await lv_text.process(config[CONF_TEXT])
@@ -239,17 +209,19 @@ async def list_add_text_to_code(config, action_id, template_arg, args):
lv.obj_move_to_index(entry, await lv_int.process(idx))
await _fire_on_add(config[CONF_ID], w.obj, entry)
return await action_to_code(
widgets, do_add_text, action_id, template_arg, args, config
)
return await action_to_code(widgets, do_add_text, action_id, template_arg, args)
# These types of widgets are not compatible with lvgl.list.add, some since they manage their own children in a way
# that can't be rebuilt on every call, others are just Too Hard.
_DYNAMIC_WIDGET_UNSUPPORTED = (
CONF_BUTTONMATRIX,
CONF_CANVAS,
CONF_KEYBOARD,
CONF_LIST,
CONF_METER,
CONF_TABVIEW,
CONF_TILEVIEW,
CONF_METER,
CONF_CANVAS,
)
@@ -372,7 +344,6 @@ async def list_add_to_code(config, action_id, template_arg, args):
_register_lv_uses(w_type_name, w_conf)
_register_dynamic_widget_style_uses(w_conf)
widgets = await get_widgets(config)
await _wait_list_triggers_completed()
async def do_add(w: Widget):
index = None
@@ -388,7 +359,7 @@ async def list_add_to_code(config, action_id, template_arg, args):
index=index,
)
return await action_to_code(widgets, do_add, action_id, template_arg, args, config)
return await action_to_code(widgets, do_add, action_id, template_arg, args)
async def _build_dynamic_widget(
@@ -507,7 +478,6 @@ LIST_REMOVE_SCHEMA = LIST_ID_SCHEMA.extend(
)
async def list_remove_to_code(config, action_id, template_arg, args):
widgets = await get_widgets(config)
await _wait_list_triggers_completed()
async def do_remove(w: Widget):
index = await lv_int.process(config[CONF_INDEX])
@@ -528,9 +498,7 @@ async def list_remove_to_code(config, action_id, template_arg, args):
# Recursively destroys the whole subtree
lv.obj_del(child)
return await action_to_code(
widgets, do_remove, action_id, template_arg, args, config
)
return await action_to_code(widgets, do_remove, action_id, template_arg, args)
@automation.register_action(
@@ -541,19 +509,15 @@ async def list_remove_to_code(config, action_id, template_arg, args):
)
async def list_clear_to_code(config, action_id, template_arg, args):
widgets = await get_widgets(config)
await _wait_list_triggers_completed()
async def do_clear(w: Widget):
await _wait_list_triggers_completed()
triggers = _get_list_triggers(config[CONF_ID]).on_remove
if triggers:
confs = _get_list_triggers(config[CONF_ID]).on_remove
if confs:
# Fire on_remove for every entry, newest to oldest, before wiping them all out,
# so on_remove's semantics ("an entry left the list") hold
with LvCountdown("list_index", lv_expr.obj_get_child_count(w.obj)) as index:
_fire_index_triggers(triggers, index)
await _fire_index_triggers(confs, index)
# lv_obj_clean recursively destroys every child's whole subtree
lv.obj_clean(w.obj)
return await action_to_code(
widgets, do_clear, action_id, template_arg, args, config
)
return await action_to_code(widgets, do_clear, action_id, template_arg, args)
+1 -3
View File
@@ -612,9 +612,7 @@ async def indicator_update_to_code(config, action_id, template_arg, args):
async def set_value(w: Widget):
await set_indicator_values(w.parent, w, config)
return await action_to_code(
widget, set_value, action_id, template_arg, args, config
)
return await action_to_code(widget, set_value, action_id, template_arg, args)
async def set_indicator_values(scale: MockObj, indicator: Widget, config):
+1 -9
View File
@@ -26,14 +26,7 @@ from ..lvcode import (
)
from ..schemas import LVGL_SCHEMA
from ..types import LvglAction, LvglCondition, lv_page_t
from . import (
Widget,
WidgetType,
add_widgets,
get_widgets,
set_obj_properties,
wait_for_widgets,
)
from . import Widget, WidgetType, add_widgets, get_widgets, set_obj_properties
CONF_ON_LOAD = "on_load"
CONF_ON_UNLOAD = "on_unload"
@@ -107,7 +100,6 @@ async def page_next_to_code(config, action_id, template_arg, args):
),
)
async def page_is_showing_to_code(config, condition_id, template_arg, args):
await wait_for_widgets()
page = await cg.get_variable(config[CONF_ID])
async with LambdaContext(
[(lv_page_t.operator("ptr"), PAGE_ARG)], return_type=cg.bool_
+1 -3
View File
@@ -274,6 +274,4 @@ async def table_cell_update_to_code(
)
await set_cell_ctrl(w, row, column, config)
return await action_to_code(
widgets, do_update, action_id, template_arg, args, config
)
return await action_to_code(widgets, do_update, action_id, template_arg, args)
@@ -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"
)