From 73d510d502a5603fb6aeff0ad96b786a6d3a81c4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Oct 2025 00:35:09 -0700 Subject: [PATCH] Stateless lambdas --- esphome/automation.py | 12 ++++++ esphome/components/binary_sensor/__init__.py | 5 +++ esphome/components/binary_sensor/filter.h | 17 +++++++++ esphome/components/logger/__init__.py | 10 ++++- esphome/components/sensor/__init__.py | 5 +++ esphome/components/sensor/filter.h | 17 +++++++++ esphome/components/text_sensor/__init__.py | 5 +++ esphome/components/text_sensor/filter.h | 17 +++++++++ esphome/core/base_automation.h | 25 ++++++++++++ esphome/cpp_generator.py | 11 +++++- tests/unit_tests/test_cpp_generator.py | 40 ++++++++++++++++++++ 11 files changed, 162 insertions(+), 2 deletions(-) diff --git a/esphome/automation.py b/esphome/automation.py index 99def9f273..a75fcf35a4 100644 --- a/esphome/automation.py +++ b/esphome/automation.py @@ -87,6 +87,7 @@ def validate_potentially_or_condition(value): DelayAction = cg.esphome_ns.class_("DelayAction", Action, cg.Component) LambdaAction = cg.esphome_ns.class_("LambdaAction", Action) +StatelessLambdaAction = cg.esphome_ns.class_("StatelessLambdaAction", Action) IfAction = cg.esphome_ns.class_("IfAction", Action) WhileAction = cg.esphome_ns.class_("WhileAction", Action) RepeatAction = cg.esphome_ns.class_("RepeatAction", Action) @@ -97,6 +98,7 @@ ResumeComponentAction = cg.esphome_ns.class_("ResumeComponentAction", Action) Automation = cg.esphome_ns.class_("Automation") LambdaCondition = cg.esphome_ns.class_("LambdaCondition", Condition) +StatelessLambdaCondition = cg.esphome_ns.class_("StatelessLambdaCondition", Condition) ForCondition = cg.esphome_ns.class_("ForCondition", Condition, cg.Component) @@ -240,6 +242,11 @@ async def lambda_condition_to_code( args: TemplateArgsType, ) -> MockObj: lambda_ = await cg.process_lambda(config, args, return_type=bool) + # Use optimized StatelessLambdaCondition for lambdas with no capture + if lambda_.capture == "": + # Override the condition_id type to use StatelessLambdaCondition + condition_id = condition_id.copy() + condition_id.type = StatelessLambdaCondition return cg.new_Pvariable(condition_id, template_arg, lambda_) @@ -406,6 +413,11 @@ async def lambda_action_to_code( args: TemplateArgsType, ) -> MockObj: lambda_ = await cg.process_lambda(config, args, return_type=cg.void) + # Use optimized StatelessLambdaAction for lambdas with no capture + if lambda_.capture == "": + # Override the action_id type to use StatelessLambdaAction + action_id = action_id.copy() + action_id.type = StatelessLambdaAction return cg.new_Pvariable(action_id, template_arg, lambda_) diff --git a/esphome/components/binary_sensor/__init__.py b/esphome/components/binary_sensor/__init__.py index 26e784a0b8..9e87adf1d1 100644 --- a/esphome/components/binary_sensor/__init__.py +++ b/esphome/components/binary_sensor/__init__.py @@ -155,6 +155,7 @@ DelayedOffFilter = binary_sensor_ns.class_("DelayedOffFilter", Filter, cg.Compon InvertFilter = binary_sensor_ns.class_("InvertFilter", Filter) AutorepeatFilter = binary_sensor_ns.class_("AutorepeatFilter", Filter, cg.Component) LambdaFilter = binary_sensor_ns.class_("LambdaFilter", Filter) +StatelessLambdaFilter = binary_sensor_ns.class_("StatelessLambdaFilter", Filter) SettleFilter = binary_sensor_ns.class_("SettleFilter", Filter, cg.Component) _LOGGER = getLogger(__name__) @@ -299,6 +300,10 @@ async def lambda_filter_to_code(config, filter_id): lambda_ = await cg.process_lambda( config, [(bool, "x")], return_type=cg.optional.template(bool) ) + # Use optimized StatelessLambdaFilter for lambdas with no capture + if lambda_.capture == "": + filter_id = filter_id.copy() + filter_id.type = StatelessLambdaFilter return cg.new_Pvariable(filter_id, lambda_) diff --git a/esphome/components/binary_sensor/filter.h b/esphome/components/binary_sensor/filter.h index a7eb080feb..7ee253ead5 100644 --- a/esphome/components/binary_sensor/filter.h +++ b/esphome/components/binary_sensor/filter.h @@ -111,6 +111,23 @@ class LambdaFilter : public Filter { std::function(bool)> f_; }; +/** Optimized lambda filter for stateless lambdas (no capture). + * + * Uses function pointer instead of std::function to reduce memory overhead. + * Memory: 8 bytes (function pointer) vs 32 bytes (std::function). + */ +class StatelessLambdaFilter : public Filter { + public: + using stateless_lambda_filter_t = optional (*)(bool); + + explicit StatelessLambdaFilter(stateless_lambda_filter_t f) : f_(f) {} + + optional new_value(bool value) override { return this->f_(value); } + + protected: + stateless_lambda_filter_t f_; +}; + class SettleFilter : public Filter, public Component { public: optional new_value(bool value) override; diff --git a/esphome/components/logger/__init__.py b/esphome/components/logger/__init__.py index 1d02073d27..61c9ef5051 100644 --- a/esphome/components/logger/__init__.py +++ b/esphome/components/logger/__init__.py @@ -1,7 +1,7 @@ import re from esphome import automation -from esphome.automation import LambdaAction +from esphome.automation import LambdaAction, StatelessLambdaAction import esphome.codegen as cg from esphome.components.esp32 import add_idf_sdkconfig_option, get_esp32_variant from esphome.components.esp32.const import ( @@ -430,6 +430,10 @@ async def logger_log_action_to_code(config, action_id, template_arg, args): text = str(cg.statement(esp_log(config[CONF_TAG], config[CONF_FORMAT], *args_))) lambda_ = await cg.process_lambda(Lambda(text), args, return_type=cg.void) + # Use optimized StatelessLambdaAction for lambdas with no capture + if lambda_.capture == "": + action_id = action_id.copy() + action_id.type = StatelessLambdaAction return cg.new_Pvariable(action_id, template_arg, lambda_) @@ -455,6 +459,10 @@ async def logger_set_level_to_code(config, action_id, template_arg, args): text = str(cg.statement(logger.set_log_level(level))) lambda_ = await cg.process_lambda(Lambda(text), args, return_type=cg.void) + # Use optimized StatelessLambdaAction for lambdas with no capture + if lambda_.capture == "": + action_id = action_id.copy() + action_id.type = StatelessLambdaAction return cg.new_Pvariable(action_id, template_arg, lambda_) diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index 93283e4d47..41e9eb63d5 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -261,6 +261,7 @@ ExponentialMovingAverageFilter = sensor_ns.class_( ) ThrottleAverageFilter = sensor_ns.class_("ThrottleAverageFilter", Filter, cg.Component) LambdaFilter = sensor_ns.class_("LambdaFilter", Filter) +StatelessLambdaFilter = sensor_ns.class_("StatelessLambdaFilter", Filter) OffsetFilter = sensor_ns.class_("OffsetFilter", Filter) MultiplyFilter = sensor_ns.class_("MultiplyFilter", Filter) ValueListFilter = sensor_ns.class_("ValueListFilter", Filter) @@ -573,6 +574,10 @@ async def lambda_filter_to_code(config, filter_id): lambda_ = await cg.process_lambda( config, [(float, "x")], return_type=cg.optional.template(float) ) + # Use optimized StatelessLambdaFilter for lambdas with no capture + if lambda_.capture == "": + filter_id = filter_id.copy() + filter_id.type = StatelessLambdaFilter return cg.new_Pvariable(filter_id, lambda_) diff --git a/esphome/components/sensor/filter.h b/esphome/components/sensor/filter.h index ecd55308d1..4f0840a75e 100644 --- a/esphome/components/sensor/filter.h +++ b/esphome/components/sensor/filter.h @@ -296,6 +296,23 @@ class LambdaFilter : public Filter { lambda_filter_t lambda_filter_; }; +/** Optimized lambda filter for stateless lambdas (no capture). + * + * Uses function pointer instead of std::function to reduce memory overhead. + * Memory: 8 bytes (function pointer) vs 32 bytes (std::function). + */ +class StatelessLambdaFilter : public Filter { + public: + using stateless_lambda_filter_t = optional (*)(float); + + explicit StatelessLambdaFilter(stateless_lambda_filter_t lambda_filter) : lambda_filter_(lambda_filter) {} + + optional new_value(float value) override { return this->lambda_filter_(value); } + + protected: + stateless_lambda_filter_t lambda_filter_; +}; + /// A simple filter that adds `offset` to each value it receives. class OffsetFilter : public Filter { public: diff --git a/esphome/components/text_sensor/__init__.py b/esphome/components/text_sensor/__init__.py index 7a9e947abd..afd9fc1733 100644 --- a/esphome/components/text_sensor/__init__.py +++ b/esphome/components/text_sensor/__init__.py @@ -57,6 +57,7 @@ validate_filters = cv.validate_registry("filter", FILTER_REGISTRY) # Filters Filter = text_sensor_ns.class_("Filter") LambdaFilter = text_sensor_ns.class_("LambdaFilter", Filter) +StatelessLambdaFilter = text_sensor_ns.class_("StatelessLambdaFilter", Filter) ToUpperFilter = text_sensor_ns.class_("ToUpperFilter", Filter) ToLowerFilter = text_sensor_ns.class_("ToLowerFilter", Filter) AppendFilter = text_sensor_ns.class_("AppendFilter", Filter) @@ -70,6 +71,10 @@ async def lambda_filter_to_code(config, filter_id): lambda_ = await cg.process_lambda( config, [(cg.std_string, "x")], return_type=cg.optional.template(cg.std_string) ) + # Use optimized StatelessLambdaFilter for lambdas with no capture + if lambda_.capture == "": + filter_id = filter_id.copy() + filter_id.type = StatelessLambdaFilter return cg.new_Pvariable(filter_id, lambda_) diff --git a/esphome/components/text_sensor/filter.h b/esphome/components/text_sensor/filter.h index c77c221235..d8c71379ad 100644 --- a/esphome/components/text_sensor/filter.h +++ b/esphome/components/text_sensor/filter.h @@ -62,6 +62,23 @@ class LambdaFilter : public Filter { lambda_filter_t lambda_filter_; }; +/** Optimized lambda filter for stateless lambdas (no capture). + * + * Uses function pointer instead of std::function to reduce memory overhead. + * Memory: 8 bytes (function pointer) vs 32 bytes (std::function). + */ +class StatelessLambdaFilter : public Filter { + public: + using stateless_lambda_filter_t = optional (*)(std::string); + + explicit StatelessLambdaFilter(stateless_lambda_filter_t lambda_filter) : lambda_filter_(lambda_filter) {} + + optional new_value(std::string value) override { return this->lambda_filter_(value); } + + protected: + stateless_lambda_filter_t lambda_filter_; +}; + /// A simple filter that converts all text to uppercase class ToUpperFilter : public Filter { public: diff --git a/esphome/core/base_automation.h b/esphome/core/base_automation.h index af8cde971b..683be2a9e9 100644 --- a/esphome/core/base_automation.h +++ b/esphome/core/base_automation.h @@ -79,6 +79,18 @@ template class LambdaCondition : public Condition { std::function f_; }; +/// Optimized lambda condition for stateless lambdas (no capture). +/// Uses function pointer instead of std::function to reduce memory overhead. +/// Memory: 8 bytes (function pointer) vs 32 bytes (std::function). +template class StatelessLambdaCondition : public Condition { + public: + explicit StatelessLambdaCondition(bool (*f)(Ts...)) : f_(f) {} + bool check(Ts... x) override { return this->f_(x...); } + + protected: + bool (*f_)(Ts...); +}; + template class ForCondition : public Condition, public Component { public: explicit ForCondition(Condition<> *condition) : condition_(condition) {} @@ -190,6 +202,19 @@ template class LambdaAction : public Action { std::function f_; }; +/// Optimized lambda action for stateless lambdas (no capture). +/// Uses function pointer instead of std::function to reduce memory overhead. +/// Memory: 8 bytes (function pointer) vs 32 bytes (std::function). +template class StatelessLambdaAction : public Action { + public: + explicit StatelessLambdaAction(void (*f)(Ts...)) : f_(f) {} + + void play(Ts... x) override { this->f_(x...); } + + protected: + void (*f_)(Ts...); +}; + template class IfAction : public Action { public: explicit IfAction(Condition *condition) : condition_(condition) {} diff --git a/esphome/cpp_generator.py b/esphome/cpp_generator.py index b2022c7ae6..4e286dfa2c 100644 --- a/esphome/cpp_generator.py +++ b/esphome/cpp_generator.py @@ -198,7 +198,10 @@ class LambdaExpression(Expression): self.return_type = safe_exp(return_type) if return_type is not None else None def __str__(self): - cpp = f"[{self.capture}]({self.parameters})" + # Unary + converts stateless lambda to function pointer + # This allows implicit conversion to void (*)() or bool (*)() + prefix = "+" if self.capture == "" else "" + cpp = f"{prefix}[{self.capture}]({self.parameters})" if self.return_type is not None: cpp += f" -> {self.return_type}" cpp += " {\n" @@ -700,6 +703,12 @@ async def process_lambda( parts[i * 3 + 1] = var parts[i * 3 + 2] = "" + # All id() references are global variables in generated C++ code. + # Global variables should not be captured - they're accessible everywhere. + # Use empty capture instead of capture-by-value. + if capture == "=": + capture = "" + if isinstance(value, ESPHomeDataBase) and value.esp_range is not None: location = value.esp_range.start_mark location.line += value.content_offset diff --git a/tests/unit_tests/test_cpp_generator.py b/tests/unit_tests/test_cpp_generator.py index 95633ca0c6..b495b52064 100644 --- a/tests/unit_tests/test_cpp_generator.py +++ b/tests/unit_tests/test_cpp_generator.py @@ -173,6 +173,46 @@ class TestLambdaExpression: "}" ) + def test_str__stateless_no_return(self): + """Test stateless lambda (empty capture) gets unary + prefix""" + target = cg.LambdaExpression( + ('ESP_LOGD("main", "Test message");',), + (), # No parameters + "", # Empty capture (stateless) + ) + + actual = str(target) + + assert actual == ('+[]() {\n ESP_LOGD("main", "Test message");\n}') + + def test_str__stateless_with_return(self): + """Test stateless lambda with return type gets unary + prefix""" + target = cg.LambdaExpression( + ("return global_value > 0;",), + (), # No parameters + "", # Empty capture (stateless) + bool, # Return type + ) + + actual = str(target) + + assert actual == ("+[]() -> bool {\n return global_value > 0;\n}") + + def test_str__stateless_with_params(self): + """Test stateless lambda with parameters gets unary + prefix""" + target = cg.LambdaExpression( + ("return foo + bar;",), + ((int, "foo"), (float, "bar")), + "", # Empty capture (stateless) + float, + ) + + actual = str(target) + + assert actual == ( + "+[](int32_t foo, float bar) -> float {\n return foo + bar;\n}" + ) + class TestLiterals: @pytest.mark.parametrize(