diff --git a/esphome/components/lvgl/__init__.py b/esphome/components/lvgl/__init__.py index 3822664c94..ca9a317fec 100644 --- a/esphome/components/lvgl/__init__.py +++ b/esphome/components/lvgl/__init__.py @@ -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, [])) diff --git a/esphome/components/lvgl/automation.py b/esphome/components/lvgl/automation.py index c23a36c389..d797688a7e 100644 --- a/esphome/components/lvgl/automation.py +++ b/esphome/components/lvgl/automation.py @@ -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): diff --git a/esphome/components/lvgl/binary_sensor/__init__.py b/esphome/components/lvgl/binary_sensor/__init__.py index aa68e76421..fc7317f8b9 100644 --- a/esphome/components/lvgl/binary_sensor/__init__.py +++ b/esphome/components/lvgl/binary_sensor/__init__.py @@ -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: diff --git a/esphome/components/lvgl/defines.py b/esphome/components/lvgl/defines.py index fbb621800b..c019dff96e 100644 --- a/esphome/components/lvgl/defines.py +++ b/esphome/components/lvgl/defines.py @@ -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", diff --git a/esphome/components/lvgl/light/__init__.py b/esphome/components/lvgl/light/__init__.py index dcdf67a520..0a8720f5ee 100644 --- a/esphome/components/lvgl/light/__init__.py +++ b/esphome/components/lvgl/light/__init__.py @@ -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)) diff --git a/esphome/components/lvgl/lvcode.py b/esphome/components/lvgl/lvcode.py index 850b63a26f..d6d1aaa414 100644 --- a/esphome/components/lvgl/lvcode.py +++ b/esphome/components/lvgl/lvcode.py @@ -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() diff --git a/esphome/components/lvgl/number/__init__.py b/esphome/components/lvgl/number/__init__.py index be51963ba1..0d8d99ab21 100644 --- a/esphome/components/lvgl/number/__init__.py +++ b/esphome/components/lvgl/number/__init__.py @@ -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: diff --git a/esphome/components/lvgl/schemas.py b/esphome/components/lvgl/schemas.py index a546d9f183..cf766b96c8 100644 --- a/esphome/components/lvgl/schemas.py +++ b/esphome/components/lvgl/schemas.py @@ -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)] = ( diff --git a/esphome/components/lvgl/sensor/__init__.py b/esphome/components/lvgl/sensor/__init__.py index e69ea9771a..c5ef98b05e 100644 --- a/esphome/components/lvgl/sensor/__init__.py +++ b/esphome/components/lvgl/sensor/__init__.py @@ -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(): diff --git a/esphome/components/lvgl/styles.py b/esphome/components/lvgl/styles.py index ad42028327..1fef37e2ee 100644 --- a/esphome/components/lvgl/styles.py +++ b/esphome/components/lvgl/styles.py @@ -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()) diff --git a/esphome/components/lvgl/switch/__init__.py b/esphome/components/lvgl/switch/__init__.py index 509e4f42ad..907665eca6 100644 --- a/esphome/components/lvgl/switch/__init__.py +++ b/esphome/components/lvgl/switch/__init__.py @@ -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: diff --git a/esphome/components/lvgl/text/__init__.py b/esphome/components/lvgl/text/__init__.py index 61db5444e8..3d5e0305b9 100644 --- a/esphome/components/lvgl/text/__init__.py +++ b/esphome/components/lvgl/text/__init__.py @@ -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) diff --git a/esphome/components/lvgl/text_sensor/__init__.py b/esphome/components/lvgl/text_sensor/__init__.py index c3306ad57a..f0575fb101 100644 --- a/esphome/components/lvgl/text_sensor/__init__.py +++ b/esphome/components/lvgl/text_sensor/__init__.py @@ -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: diff --git a/esphome/components/lvgl/widgets/__init__.py b/esphome/components/lvgl/widgets/__init__.py index da513e05a8..d69e90056f 100644 --- a/esphome/components/lvgl/widgets/__init__.py +++ b/esphome/components/lvgl/widgets/__init__.py @@ -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 { diff --git a/esphome/components/lvgl/widgets/button.py b/esphome/components/lvgl/widgets/button.py index 0ad512cd8b..306f692501 100644 --- a/esphome/components/lvgl/widgets/button.py +++ b/esphome/components/lvgl/widgets/button.py @@ -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) diff --git a/esphome/components/lvgl/widgets/buttonmatrix.py b/esphome/components/lvgl/widgets/buttonmatrix.py index 02dc9ed4ba..854c5e3127 100644 --- a/esphome/components/lvgl/widgets/buttonmatrix.py +++ b/esphome/components/lvgl/widgets/buttonmatrix.py @@ -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 ) diff --git a/esphome/components/lvgl/widgets/canvas.py b/esphome/components/lvgl/widgets/canvas.py index 4427a3b00e..2137acb3f6 100644 --- a/esphome/components/lvgl/widgets/canvas.py +++ b/esphome/components/lvgl/widgets/canvas.py @@ -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 = { diff --git a/esphome/components/lvgl/widgets/keyboard.py b/esphome/components/lvgl/widgets/keyboard.py index 65516513a6..0356e66dac 100644 --- a/esphome/components/lvgl/widgets/keyboard.py +++ b/esphome/components/lvgl/widgets/keyboard.py @@ -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() diff --git a/esphome/components/lvgl/widgets/lv_list.py b/esphome/components/lvgl/widgets/lv_list.py index 5d94c2565a..f41cb8e4e9 100644 --- a/esphome/components/lvgl/widgets/lv_list.py +++ b/esphome/components/lvgl/widgets/lv_list.py @@ -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) diff --git a/esphome/components/lvgl/widgets/meter.py b/esphome/components/lvgl/widgets/meter.py index 166e88f382..ba32288128 100644 --- a/esphome/components/lvgl/widgets/meter.py +++ b/esphome/components/lvgl/widgets/meter.py @@ -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): diff --git a/esphome/components/lvgl/widgets/page.py b/esphome/components/lvgl/widgets/page.py index 7e75ab6a2d..9dc9123ac9 100644 --- a/esphome/components/lvgl/widgets/page.py +++ b/esphome/components/lvgl/widgets/page.py @@ -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_ diff --git a/esphome/components/lvgl/widgets/table.py b/esphome/components/lvgl/widgets/table.py index f000ea1846..019c6d4eca 100644 --- a/esphome/components/lvgl/widgets/table.py +++ b/esphome/components/lvgl/widgets/table.py @@ -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) diff --git a/tests/component_tests/lvgl/config/keyboard_before_textarea_test.yaml b/tests/component_tests/lvgl/config/keyboard_before_textarea_test.yaml new file mode 100644 index 0000000000..8f80a942f5 --- /dev/null +++ b/tests/component_tests/lvgl/config/keyboard_before_textarea_test.yaml @@ -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 diff --git a/tests/component_tests/lvgl/config/keyboard_update_textarea_test.yaml b/tests/component_tests/lvgl/config/keyboard_update_textarea_test.yaml new file mode 100644 index 0000000000..1d02a2df58 --- /dev/null +++ b/tests/component_tests/lvgl/config/keyboard_update_textarea_test.yaml @@ -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 diff --git a/tests/component_tests/lvgl/config/multi_instance_theme_test.yaml b/tests/component_tests/lvgl/config/multi_instance_theme_test.yaml new file mode 100644 index 0000000000..d30f1ff29a --- /dev/null +++ b/tests/component_tests/lvgl/config/multi_instance_theme_test.yaml @@ -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" diff --git a/tests/component_tests/lvgl/test_keyboard_before_textarea.py b/tests/component_tests/lvgl/test_keyboard_before_textarea.py new file mode 100644 index 0000000000..3da4b1cebe --- /dev/null +++ b/tests/component_tests/lvgl/test_keyboard_before_textarea.py @@ -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(") diff --git a/tests/component_tests/lvgl/test_keyboard_update_textarea.py b/tests/component_tests/lvgl/test_keyboard_update_textarea.py new file mode 100644 index 0000000000..154d2ee200 --- /dev/null +++ b/tests/component_tests/lvgl/test_keyboard_update_textarea.py @@ -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 diff --git a/tests/component_tests/lvgl/test_list.py b/tests/component_tests/lvgl/test_list.py index 93ee162f1a..368a3a4990 100644 --- a/tests/component_tests/lvgl/test_list.py +++ b/tests/component_tests/lvgl/test_list.py @@ -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 diff --git a/tests/component_tests/lvgl/test_list_on_add_lvgl_action.py b/tests/component_tests/lvgl/test_list_on_add_lvgl_action.py index 2383adc251..9ff7b8736e 100644 --- a/tests/component_tests/lvgl/test_list_on_add_lvgl_action.py +++ b/tests/component_tests/lvgl/test_list_on_add_lvgl_action.py @@ -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 diff --git a/tests/component_tests/lvgl/test_multi_instance_theme.py b/tests/component_tests/lvgl/test_multi_instance_theme.py new file mode 100644 index 0000000000..82f271b605 --- /dev/null +++ b/tests/component_tests/lvgl/test_multi_instance_theme.py @@ -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 diff --git a/tests/components/lvgl/lvgl-package.yaml b/tests/components/lvgl/lvgl-package.yaml index e10ca68ac6..18a7fd0913 100644 --- a/tests/components/lvgl/lvgl-package.yaml +++ b/tests/components/lvgl/lvgl-package.yaml @@ -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 diff --git a/tests/unit_tests/components/lvgl/test_action_to_code_context_isolation.py b/tests/unit_tests/components/lvgl/test_action_to_code_context_isolation.py new file mode 100644 index 0000000000..0842bc8707 --- /dev/null +++ b/tests/unit_tests/components/lvgl/test_action_to_code_context_isolation.py @@ -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 diff --git a/tests/unit_tests/components/lvgl/test_get_part_state_selector.py b/tests/unit_tests/components/lvgl/test_get_part_state_selector.py new file mode 100644 index 0000000000..e7d623b7c6 --- /dev/null +++ b/tests/unit_tests/components/lvgl/test_get_part_state_selector.py @@ -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_STATE_PRESSED) | " + "static_cast(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_STATE_PRESSED) | " + "static_cast(LV_PART_KNOB))" + ) diff --git a/tests/unit_tests/components/lvgl/test_list_trigger_ordering.py b/tests/unit_tests/components/lvgl/test_list_trigger_ordering.py new file mode 100644 index 0000000000..6caf83fe33 --- /dev/null +++ b/tests/unit_tests/components/lvgl/test_list_trigger_ordering.py @@ -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" + ) diff --git a/tests/unit_tests/components/lvgl/test_table_codegen.py b/tests/unit_tests/components/lvgl/test_table_codegen.py index 390f67dffc..12ebd3a12f 100644 --- a/tests/unit_tests/components/lvgl/test_table_codegen.py +++ b/tests/unit_tests/components/lvgl/test_table_codegen.py @@ -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() diff --git a/tests/unit_tests/components/lvgl/test_theme_update_ordering.py b/tests/unit_tests/components/lvgl/test_theme_update_ordering.py new file mode 100644 index 0000000000..62b8a4eaed --- /dev/null +++ b/tests/unit_tests/components/lvgl/test_theme_update_ordering.py @@ -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" + )