From eecea15f4f714af7dd7278cd2433ee3c800db92f Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Sat, 12 Sep 2026 09:42:08 +1000 Subject: [PATCH] [core][lvgl] Migrate codegen helpers from LVGL to core code (#19105) Co-authored-by: Claude Sonnet 5 --- esphome/components/lvgl/automation.py | 3 +- esphome/components/lvgl/defines.py | 43 +---------- esphome/components/lvgl/lv_validation.py | 4 +- esphome/components/lvgl/widgets/__init__.py | 3 +- esphome/cpp_generator.py | 39 ++++++++++ tests/unit_tests/test_cpp_generator.py | 79 +++++++++++++++++++++ 6 files changed, 122 insertions(+), 49 deletions(-) diff --git a/esphome/components/lvgl/automation.py b/esphome/components/lvgl/automation.py index a62f466413..c23a36c389 100644 --- a/esphome/components/lvgl/automation.py +++ b/esphome/components/lvgl/automation.py @@ -14,7 +14,7 @@ from esphome.const import ( CONF_TIMEOUT, ) from esphome.core import Lambda -from esphome.cpp_generator import TemplateArguments, get_variable +from esphome.cpp_generator import StaticCastExpression, TemplateArguments, get_variable from esphome.cpp_types import nullptr from .defines import ( @@ -30,7 +30,6 @@ from .defines import ( CONF_SHOW_SNOW, CONF_TOP_LAYER, PARTS, - StaticCastExpression, add_warning, get_focused_widgets, get_options, diff --git a/esphome/components/lvgl/defines.py b/esphome/components/lvgl/defines.py index 1eee8041f9..73fc58736b 100644 --- a/esphome/components/lvgl/defines.py +++ b/esphome/components/lvgl/defines.py @@ -10,12 +10,7 @@ from typing import Any from esphome import codegen as cg, config_validation as cv from esphome.const import CONF_ITEMS from esphome.core import CORE, ID, Lambda -from esphome.cpp_generator import ( - CallExpression, - LambdaExpression, - MockObj, - MockObjClass, -) +from esphome.cpp_generator import MockObj, StaticCastExpression, call_lambda from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor from esphome.types import Expression, SafeExpType @@ -157,17 +152,6 @@ def get_refreshed_widgets() -> set: return _get_data(KEY_REFRESHED_WIDGETS, set()) -class StaticCastExpression(Expression): - __slots__ = ("type", "exp") - - def __init__(self, type: Any, exp: SafeExpType): - self.type = str(type) - self.exp = cg.safe_exp(exp) - - def __str__(self): - return f"static_cast<{self.type}>({self.exp})" - - def add_define(macro: str, value="1"): lv_defines = get_defines() value = str(value) @@ -192,31 +176,6 @@ def addr(arg) -> MockObj: return MockObj(f"&{arg}") -def call_lambda(lamb: LambdaExpression) -> Expression: - """ - Given a lambda, either reduce to a simple expression or call it, possibly with parameters - from the surrounding context - :param lamb: - :return: - """ - expr = lamb.content.strip() - if expr.startswith("return") and expr.endswith(";"): - # Convert a lambda returning a simple expression to just that expression - expr = cg.RawExpression(expr[6:-1].strip()) - # Don't cast if the return type is a class - if isinstance(lamb.return_type, MockObjClass): - return expr - return StaticCastExpression(lamb.return_type, expr) - # If lambda has parameters, call it with their 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: - return CallExpression( - lamb, *[MockObj(x.id) for x in lamb.parameters.parameters] - ) - return CallExpression(lamb) - - class LValidator: """ A validator for a particular type used in LVGL. Usable in configs as a validator, also diff --git a/esphome/components/lvgl/lv_validation.py b/esphome/components/lvgl/lv_validation.py index 42352b9602..6f86e49e51 100644 --- a/esphome/components/lvgl/lv_validation.py +++ b/esphome/components/lvgl/lv_validation.py @@ -16,7 +16,7 @@ from esphome.const import ( CONF_VALUE, ) from esphome.core import CORE, ID, Lambda -from esphome.cpp_generator import MockObj +from esphome.cpp_generator import MockObj, StaticCastExpression, call_lambda from esphome.cpp_types import ESPTime, int32, uint32 from esphome.helpers import cpp_string_escape from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor @@ -33,9 +33,7 @@ from .defines import ( LV_FONTS, LValidator, LvConstant, - StaticCastExpression, add_lv_use, - call_lambda, get_esphome_fonts_used, get_lv_fonts_used, get_lv_images_used, diff --git a/esphome/components/lvgl/widgets/__init__.py b/esphome/components/lvgl/widgets/__init__.py index c9099e3c3a..a524fe761f 100644 --- a/esphome/components/lvgl/widgets/__init__.py +++ b/esphome/components/lvgl/widgets/__init__.py @@ -16,7 +16,7 @@ from esphome.const import ( ) from esphome.core import ID, EsphomeError, TimePeriod from esphome.coroutine import FakeAwaitable -from esphome.cpp_generator import MockObj +from esphome.cpp_generator import MockObj, call_lambda from esphome.schema_extractors import EnableSchemaExtraction from esphome.types import Expression @@ -42,7 +42,6 @@ from ..defines import ( STATES, LValidator, add_lv_use, - call_lambda, get_styles_used, get_theme_widget_map, get_widget_map, diff --git a/esphome/cpp_generator.py b/esphome/cpp_generator.py index e6b8c0de42..173002438a 100644 --- a/esphome/cpp_generator.py +++ b/esphome/cpp_generator.py @@ -1187,3 +1187,42 @@ class MockObjClass(MockObj): def __repr__(self): return f"MockObjClass<{str(self.base)}, parents={self._parents}>" + + +class StaticCastExpression(Expression): + __slots__ = ("type", "exp") + + def __init__(self, type: Any, exp: SafeExpType): + self.type = str(type) + self.exp = safe_exp(exp) + + def __str__(self): + return f"static_cast<{self.type}>({self.exp})" + + +def call_lambda(lamb: LambdaExpression) -> Expression: + """ + Given a lambda, either reduce to a simple expression or call it, possibly with parameters + from the surrounding context. + This is for use only with value-returning lambdas, used in places where the value of a lambda call is needed. + :param lamb: The LambdaExpression to call or reduce + :return: An Expression representing the result of calling the lambda or reducing it to a simple expression + """ + # Developer error if this is called with a lambda that doesn't have a return type + assert lamb.return_type is not None, "Lambda must have a return type to be called" + expr = lamb.content.strip() + if re.match(r"^return\b", expr) and expr.endswith(";"): + # Convert a lambda returning a simple expression to just that expression + expr = RawExpression(expr[6:-1].strip()) + # Don't cast if the return type is a class + if isinstance(lamb.return_type, MockObjClass): + return expr + return StaticCastExpression(lamb.return_type, expr) + # If lambda has parameters, call it with their 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: + return CallExpression( + lamb, *[MockObj(x.id) for x in lamb.parameters.parameters] + ) + return CallExpression(lamb) diff --git a/tests/unit_tests/test_cpp_generator.py b/tests/unit_tests/test_cpp_generator.py index 81ae586e23..052513ce97 100644 --- a/tests/unit_tests/test_cpp_generator.py +++ b/tests/unit_tests/test_cpp_generator.py @@ -85,6 +85,15 @@ class TestCallExpression: assert actual == 'my_function(1, "2", false)' +class TestStaticCastExpression: + def test_str(self): + target = cg.StaticCastExpression(ct.bool_, 42) + + actual = str(target) + + assert actual == "static_cast(42)" + + class TestStructInitializer: def test_str(self): target = cg.StructInitializer( @@ -229,6 +238,76 @@ class TestLambdaExpression: ) +class TestCallLambda: + """Tests for the call_lambda() function.""" + + def test_call_lambda__return_expression_casts_to_return_type(self): + """A lambda body that is just a return statement reduces to the + expression, cast to the lambda's return type.""" + lamb = cg.LambdaExpression(("return foo + 1;",), (), "", ct.bool_) + + result = cg.call_lambda(lamb) + + assert isinstance(result, cg.StaticCastExpression) + assert str(result) == "static_cast(foo + 1)" + + def test_call_lambda__return_expression_with_class_return_type_no_cast(self): + """A class return type is not cast, since static_cast doesn't apply + to arbitrary class types.""" + mock_class = cg.MockObjClass("foo::Bar", parents=()) + lamb = cg.LambdaExpression(("return get_bar();",), (), "", mock_class) + + result = cg.call_lambda(lamb) + + assert isinstance(result, cg.RawExpression) + assert str(result) == "get_bar()" + + def test_call_lambda__no_return_with_parameters_calls_with_names(self): + """A multi-statement lambda with parameters is called with the + parameter names as arguments.""" + lamb = cg.LambdaExpression( + ("do_something(x, y);",), ((int, "x"), (float, "y")), "=", ct.bool_ + ) + + result = cg.call_lambda(lamb) + + assert isinstance(result, cg.CallExpression) + assert str(result) == ( + "[=](int32_t x, float y) -> bool {\n do_something(x, y);\n}(x, y)" + ) + + def test_call_lambda__no_return_type_raises(self): + """Calling a lambda with no declared return type is a developer + error: call_lambda is only for value-returning lambdas.""" + lamb = cg.LambdaExpression(("do_something();",), (), "=") + + with pytest.raises(AssertionError): + cg.call_lambda(lamb) + + def test_call_lambda__identifier_starting_with_return_is_not_a_return_statement( + self, + ): + """A body that merely starts with the substring "return" (e.g. a call + to a function named returnValue()) must not be mistaken for a return + statement -- the match requires a word boundary after "return".""" + lamb = cg.LambdaExpression(("returnValue();",), (), "=", ct.bool_) + + result = cg.call_lambda(lamb) + + assert isinstance(result, cg.CallExpression) + assert str(result) == "[=]() -> bool {\n returnValue();\n}()" + + def test_call_lambda__no_return_no_parameters_calls_with_no_args(self): + """A multi-statement lambda without parameters is called with no + arguments.""" + lamb = cg.LambdaExpression(("do_something();",), (), "", ct.bool_) + + result = cg.call_lambda(lamb) + + assert isinstance(result, cg.CallExpression) + assert str(result) == "[]() -> bool {\n do_something();\n}()" + + class TestLiterals: @pytest.mark.parametrize( "target, expected",