mirror of
https://github.com/esphome/esphome.git
synced 2026-10-01 00:40:21 +00:00
[lvgl] Use contextvars to improve lvgl scheduling (#19242)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
e111f1130c
commit
5baad05926
@@ -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, []))
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)] = (
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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_
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
)
|
||||
Reference in New Issue
Block a user