From 66cf7c3a3b30a0441cb2c660bcb19bbb2c2a9943 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Oct 2025 01:07:48 -0500 Subject: [PATCH] [lvgl] Fix nested lambdas in automations unable to access parameters (#11583) Co-authored-by: clydebarrow <2366188+clydebarrow@users.noreply.github.com> --- esphome/components/lvgl/defines.py | 22 +++++++++++++-- esphome/components/lvgl/lv_validation.py | 21 ++++++++++++--- esphome/components/lvgl/lvcode.py | 13 ++++++--- esphome/components/lvgl/sensor/__init__.py | 3 +-- tests/components/lvgl/common.yaml | 31 ++++++++++++++++++++++ tests/components/lvgl/lvgl-package.yaml | 23 ++++++++++++++++ 6 files changed, 103 insertions(+), 10 deletions(-) diff --git a/esphome/components/lvgl/defines.py b/esphome/components/lvgl/defines.py index 6464824c64..7fbb6de071 100644 --- a/esphome/components/lvgl/defines.py +++ b/esphome/components/lvgl/defines.py @@ -5,6 +5,7 @@ Constants already defined in esphome.const are not duplicated here and must be i """ import logging +from typing import TYPE_CHECKING, Any from esphome import codegen as cg, config_validation as cv from esphome.const import CONF_ITEMS @@ -12,6 +13,7 @@ from esphome.core import ID, Lambda from esphome.cpp_generator import LambdaExpression, MockObj from esphome.cpp_types import uint32 from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor +from esphome.types import Expression, SafeExpType from .helpers import requires_component @@ -42,7 +44,13 @@ def static_cast(type, value): def call_lambda(lamb: LambdaExpression): expr = lamb.content.strip() if expr.startswith("return") and expr.endswith(";"): - return expr[6:][:-1].strip() + return expr[6:-1].strip() + # If lambda has parameters, call it with those parameter names + # Parameter names come from hardcoded component code (like "x", "it", "event") + # not from user input, so they're safe to use directly + if lamb.parameters and lamb.parameters.parameters: + param_names = ", ".join(str(param.id) for param in lamb.parameters.parameters) + return f"{lamb}({param_names})" return f"{lamb}()" @@ -65,10 +73,20 @@ class LValidator: return cv.returning_lambda(value) return self.validator(value) - async def process(self, value, args=()): + async def process( + self, value: Any, args: list[tuple[SafeExpType, str]] | None = None + ) -> Expression: if value is None: return None if isinstance(value, Lambda): + # Local import to avoid circular import + from .lvcode import CodeContext, LambdaContext + + if TYPE_CHECKING: + # CodeContext does not have get_automation_parameters + # so we need to assert the type here + assert isinstance(CodeContext.code_context, LambdaContext) + args = args or CodeContext.code_context.get_automation_parameters() return cg.RawExpression( call_lambda( await cg.process_lambda(value, args, return_type=self.rtype) diff --git a/esphome/components/lvgl/lv_validation.py b/esphome/components/lvgl/lv_validation.py index d345ac70f3..6f95a32a18 100644 --- a/esphome/components/lvgl/lv_validation.py +++ b/esphome/components/lvgl/lv_validation.py @@ -1,3 +1,5 @@ +from typing import TYPE_CHECKING, Any + import esphome.codegen as cg from esphome.components import image from esphome.components.color import CONF_HEX, ColorStruct, from_rgbw @@ -17,6 +19,7 @@ from esphome.cpp_generator import MockObj from esphome.cpp_types import ESPTime, int32, uint32 from esphome.helpers import cpp_string_escape from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor +from esphome.types import Expression, SafeExpType from . import types as ty from .defines import ( @@ -388,11 +391,23 @@ class TextValidator(LValidator): return value return super().__call__(value) - async def process(self, value, args=()): + async def process( + self, value: Any, args: list[tuple[SafeExpType, str]] | None = None + ) -> Expression: + # Local import to avoid circular import at module level + + from .lvcode import CodeContext, LambdaContext + + if TYPE_CHECKING: + # CodeContext does not have get_automation_parameters + # so we need to assert the type here + assert isinstance(CodeContext.code_context, LambdaContext) + args = args or CodeContext.code_context.get_automation_parameters() + if isinstance(value, dict): if format_str := value.get(CONF_FORMAT): - args = [str(x) for x in value[CONF_ARGS]] - arg_expr = cg.RawExpression(",".join(args)) + str_args = [str(x) for x in value[CONF_ARGS]] + arg_expr = cg.RawExpression(",".join(str_args)) format_str = cpp_string_escape(format_str) return literal(f"str_sprintf({format_str}, {arg_expr}).c_str()") if time_format := value.get(CONF_TIME_FORMAT): diff --git a/esphome/components/lvgl/lvcode.py b/esphome/components/lvgl/lvcode.py index 7a5c35f896..ea38845c07 100644 --- a/esphome/components/lvgl/lvcode.py +++ b/esphome/components/lvgl/lvcode.py @@ -164,6 +164,9 @@ class LambdaContext(CodeContext): code_text.append(text) return code_text + def get_automation_parameters(self) -> list[tuple[SafeExpType, str]]: + return self.parameters + async def __aenter__(self): await super().__aenter__() add_line_marks(self.where) @@ -178,9 +181,8 @@ class LvContext(LambdaContext): added_lambda_count = 0 - def __init__(self, args=None): - self.args = args or LVGL_COMP_ARG - super().__init__(parameters=self.args) + def __init__(self): + super().__init__(parameters=LVGL_COMP_ARG) async def __aexit__(self, exc_type, exc_val, exc_tb): await super().__aexit__(exc_type, exc_val, exc_tb) @@ -189,6 +191,11 @@ class LvContext(LambdaContext): cg.add(expression) return expression + def get_automation_parameters(self) -> list[tuple[SafeExpType, str]]: + # When generating automations, we don't want the `lv_component` parameter to be passed + # to the lambda. + return [] + def __call__(self, *args): return self.add(*args) diff --git a/esphome/components/lvgl/sensor/__init__.py b/esphome/components/lvgl/sensor/__init__.py index 03b2638ed0..167af9c6e1 100644 --- a/esphome/components/lvgl/sensor/__init__.py +++ b/esphome/components/lvgl/sensor/__init__.py @@ -5,7 +5,6 @@ from ..defines import CONF_WIDGET from ..lvcode import ( API_EVENT, EVENT_ARG, - LVGL_COMP_ARG, UPDATE_EVENT, LambdaContext, LvContext, @@ -30,7 +29,7 @@ async def to_code(config): await wait_for_widgets() async with LambdaContext(EVENT_ARG) as lamb: lv_add(sensor.publish_state(widget.get_value())) - async with LvContext(LVGL_COMP_ARG): + async with LvContext(): lv_add( lvgl_static.add_event_cb( widget.obj, diff --git a/tests/components/lvgl/common.yaml b/tests/components/lvgl/common.yaml index d9b7013a1e..c70dd7568d 100644 --- a/tests/components/lvgl/common.yaml +++ b/tests/components/lvgl/common.yaml @@ -52,6 +52,19 @@ number: widget: spinbox_id id: lvgl_spinbox_number name: LVGL Spinbox Number + - platform: template + id: test_brightness + name: "Test Brightness" + min_value: 0 + max_value: 255 + step: 1 + optimistic: true + # Test lambda in automation accessing x parameter directly + # This is a real-world pattern from user configs + on_value: + - lambda: !lambda |- + // Direct use of x parameter in automation + ESP_LOGD("test", "Brightness: %.0f", x); light: - platform: lvgl @@ -110,3 +123,21 @@ text: platform: lvgl widget: hello_label mode: text + +text_sensor: + - platform: template + id: test_text_sensor + name: "Test Text Sensor" + # Test nested lambdas in LVGL actions can access automation parameters + on_value: + - lvgl.label.update: + id: hello_label + text: !lambda return x.c_str(); + - lvgl.label.update: + id: hello_label + text: !lambda |- + // Test complex lambda with conditionals accessing x parameter + if (x == "*") { + return "WILDCARD"; + } + return x.c_str(); diff --git a/tests/components/lvgl/lvgl-package.yaml b/tests/components/lvgl/lvgl-package.yaml index 582531e943..14241a1669 100644 --- a/tests/components/lvgl/lvgl-package.yaml +++ b/tests/components/lvgl/lvgl-package.yaml @@ -257,7 +257,30 @@ lvgl: text: "Hello shiny day" text_color: 0xFFFFFF align: bottom_mid + - label: + id: setup_lambda_label + # Test lambda in widget property during setup (LvContext) + # Should NOT receive lv_component parameter + text: !lambda |- + char buf[32]; + snprintf(buf, sizeof(buf), "Setup: %d", 42); + return std::string(buf); + align: top_mid text_font: space16 + - label: + id: chip_info_label + # Test complex setup lambda (real-world pattern) + # Should NOT receive lv_component parameter + text: !lambda |- + // Test conditional compilation and string formatting + char buf[64]; + #ifdef USE_ESP_IDF + snprintf(buf, sizeof(buf), "IDF: v%d.%d", ESP_IDF_VERSION_MAJOR, ESP_IDF_VERSION_MINOR); + #else + snprintf(buf, sizeof(buf), "Arduino"); + #endif + return std::string(buf); + align: top_left - obj: align: center arc_opa: COVER