From 9bf013352548bebeee39b66403bfbd137d7ee928 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 7 Apr 2026 13:16:14 -1000 Subject: [PATCH 01/20] [core] Specialize TemplatableValue for non-string types as function pointer Specialize TemplatableValue so non-string types use function-pointer-only storage (4 bytes on 32-bit) instead of the tagged union with std::function support (8 bytes). The std::string specialization retains full support for VALUE, STATIC_STRING, FLASH_STRING, and stateful lambdas. Codegen now wraps non-string constants in stateless lambdas automatically, so the generated C++ always assigns a function pointer. --- esphome/components/api/user_services.h | 2 +- esphome/components/light/automation.h | 75 +++++----- esphome/components/light/automation.py | 38 +---- esphome/components/number/automation.h | 4 +- esphome/components/sensor/automation.h | 4 +- esphome/core/automation.h | 191 +++++++++++-------------- esphome/cpp_generator.py | 34 +++-- tests/unit_tests/test_cpp_generator.py | 5 +- 8 files changed, 161 insertions(+), 192 deletions(-) diff --git a/esphome/components/api/user_services.h b/esphome/components/api/user_services.h index d1b8a6ef0d..1f35be7ef9 100644 --- a/esphome/components/api/user_services.h +++ b/esphome/components/api/user_services.h @@ -275,7 +275,7 @@ template class APIRespondAction : public Action { protected: APIServer *parent_; - TemplatableValue success_{true}; + TemplatableValue success_{[](Ts...) -> bool { return true; }}; TemplatableValue error_message_{""}; #ifdef USE_API_USER_DEFINED_ACTION_RESPONSES_JSON std::function json_builder_; diff --git a/esphome/components/light/automation.h b/esphome/components/light/automation.h index a5c9220a23..f6a2ca52d4 100644 --- a/esphome/components/light/automation.h +++ b/esphome/components/light/automation.h @@ -24,51 +24,60 @@ template class ToggleAction : public Action { LightState *state_; }; -/// Compact light control action — each field is a function pointer (nullptr = unset). -/// Codegen wraps constants in stateless lambdas. 72 bytes vs 128 with TemplatableValue. template class LightControlAction : public Action { public: explicit LightControlAction(LightState *parent) : parent_(parent) {} -#define LIGHT_CONTROL_FIELDS(X) \ - X(ColorMode, color_mode) \ - X(bool, state) \ - X(uint32_t, transition_length) \ - X(uint32_t, flash_length) \ - X(float, brightness) \ - X(float, color_brightness) \ - X(float, red) \ - X(float, green) \ - X(float, blue) \ - X(float, white) \ - X(float, color_temperature) \ - X(float, cold_white) \ - X(float, warm_white) \ - X(uint32_t, effect) - -#define LIGHT_FIELD_SETTER_(type, name) \ - void set_##name(type (*f)(Ts...)) { this->name##_ = f; } -#define LIGHT_FIELD_APPLY_(type, name) \ - if (this->name##_) \ - call.set_##name(this->name##_(x...)); -#define LIGHT_FIELD_DECL_(type, name) type (*name##_)(Ts...){nullptr}; - - LIGHT_CONTROL_FIELDS(LIGHT_FIELD_SETTER_) + TEMPLATABLE_VALUE(ColorMode, color_mode) + TEMPLATABLE_VALUE(bool, state) + TEMPLATABLE_VALUE(uint32_t, transition_length) + TEMPLATABLE_VALUE(uint32_t, flash_length) + TEMPLATABLE_VALUE(float, brightness) + TEMPLATABLE_VALUE(float, color_brightness) + TEMPLATABLE_VALUE(float, red) + TEMPLATABLE_VALUE(float, green) + TEMPLATABLE_VALUE(float, blue) + TEMPLATABLE_VALUE(float, white) + TEMPLATABLE_VALUE(float, color_temperature) + TEMPLATABLE_VALUE(float, cold_white) + TEMPLATABLE_VALUE(float, warm_white) + TEMPLATABLE_VALUE(uint32_t, effect) void play(const Ts &...x) override { auto call = this->parent_->make_call(); - LIGHT_CONTROL_FIELDS(LIGHT_FIELD_APPLY_) + if (this->color_mode_.has_value()) + call.set_color_mode(this->color_mode_.value(x...)); + if (this->state_.has_value()) + call.set_state(this->state_.value(x...)); + if (this->transition_length_.has_value()) + call.set_transition_length(this->transition_length_.value(x...)); + if (this->flash_length_.has_value()) + call.set_flash_length(this->flash_length_.value(x...)); + if (this->brightness_.has_value()) + call.set_brightness(this->brightness_.value(x...)); + if (this->color_brightness_.has_value()) + call.set_color_brightness(this->color_brightness_.value(x...)); + if (this->red_.has_value()) + call.set_red(this->red_.value(x...)); + if (this->green_.has_value()) + call.set_green(this->green_.value(x...)); + if (this->blue_.has_value()) + call.set_blue(this->blue_.value(x...)); + if (this->white_.has_value()) + call.set_white(this->white_.value(x...)); + if (this->color_temperature_.has_value()) + call.set_color_temperature(this->color_temperature_.value(x...)); + if (this->cold_white_.has_value()) + call.set_cold_white(this->cold_white_.value(x...)); + if (this->warm_white_.has_value()) + call.set_warm_white(this->warm_white_.value(x...)); + if (this->effect_.has_value()) + call.set_effect(this->effect_.value(x...)); call.perform(); } protected: LightState *parent_; - LIGHT_CONTROL_FIELDS(LIGHT_FIELD_DECL_) - -#undef LIGHT_FIELD_DECL_ -#undef LIGHT_FIELD_APPLY_ -#undef LIGHT_FIELD_SETTER_ -#undef LIGHT_CONTROL_FIELDS }; template class DimRelativeAction : public Action { diff --git a/esphome/components/light/automation.py b/esphome/components/light/automation.py index 365a64584c..2400822b31 100644 --- a/esphome/components/light/automation.py +++ b/esphome/components/light/automation.py @@ -1,5 +1,3 @@ -from typing import Any - from esphome import automation import esphome.codegen as cg from esphome.config import path_context @@ -30,7 +28,7 @@ from esphome.const import ( ) from esphome.core import CORE, EsphomeError, Lambda from esphome.cpp_generator import LambdaExpression -from esphome.types import ConfigType, SafeExpType +from esphome.types import ConfigType from .types import ( COLOR_MODES, @@ -143,28 +141,6 @@ LIGHT_TURN_ON_ACTION_SCHEMA = automation.maybe_simple_id( ) -async def _as_lambda( - value: Any, - args: list[tuple[SafeExpType, str]], - output_type: SafeExpType, -) -> LambdaExpression: - """Return a stateless lambda expression for a templatable value. - - If value is already a lambda, process it normally. Otherwise wrap - the constant in a ``[](...) -> T { return ; }`` expression - so that LightControlAction can store every field as a plain - function pointer. - """ - if cg.is_template(value): - return await cg.process_lambda(value, args, return_type=output_type) - return LambdaExpression( - f"return {cg.safe_exp(value)};", - args, - capture="", - return_type=output_type, - ) - - def _resolve_effect_index(config: ConfigType) -> int: """Resolve a static effect name to its 1-based index at codegen time. @@ -222,9 +198,8 @@ async def light_control_to_code(config, action_id, template_arg, args): ) for conf_key, setter, type_ in FIELDS: if conf_key in config: - cg.add( - getattr(var, setter)(await _as_lambda(config[conf_key], args, type_)) - ) + template_ = await cg.templatable(config[conf_key], args, type_) + cg.add(getattr(var, setter)(template_)) if CONF_EFFECT in config: if isinstance(config[CONF_EFFECT], Lambda): @@ -248,11 +223,10 @@ async def light_control_to_code(config, action_id, template_arg, args): cg.add(var.set_effect(wrapper)) else: # Static string — resolve effect name to index at codegen time - cg.add( - var.set_effect( - await _as_lambda(_resolve_effect_index(config), args, cg.uint32) - ) + template_ = await cg.templatable( + _resolve_effect_index(config), args, cg.uint32 ) + cg.add(var.set_effect(template_)) return var diff --git a/esphome/components/number/automation.h b/esphome/components/number/automation.h index a7cd04f083..834998b32b 100644 --- a/esphome/components/number/automation.h +++ b/esphome/components/number/automation.h @@ -63,8 +63,8 @@ class ValueRangeTrigger : public Trigger, public Component { Number *parent_; ESPPreferenceObject rtc_; bool previous_in_range_{false}; - TemplatableValue min_{NAN}; - TemplatableValue max_{NAN}; + TemplatableValue min_{[](float) -> float { return NAN; }}; // NAN = no bound + TemplatableValue max_{[](float) -> float { return NAN; }}; // NAN = no bound }; template class NumberInRangeCondition : public Condition { diff --git a/esphome/components/sensor/automation.h b/esphome/components/sensor/automation.h index b4de712727..989ee2317b 100644 --- a/esphome/components/sensor/automation.h +++ b/esphome/components/sensor/automation.h @@ -79,8 +79,8 @@ class ValueRangeTrigger : public Trigger, public Component { Sensor *parent_; ESPPreferenceObject rtc_; bool previous_in_range_{false}; - TemplatableValue min_{NAN}; - TemplatableValue max_{NAN}; + TemplatableValue min_{[](float) -> float { return NAN; }}; + TemplatableValue max_{[](float) -> float { return NAN; }}; }; template class SensorInRangeCondition : public Condition { diff --git a/esphome/core/automation.h b/esphome/core/automation.h index 05c7f19588..7879478f5b 100644 --- a/esphome/core/automation.h +++ b/esphome/core/automation.h @@ -43,61 +43,78 @@ template struct gens<0, S...> { using type = seq; }; #define TEMPLATABLE_VALUE(type, name) TEMPLATABLE_VALUE_(type, name) +/// Primary template: function-pointer-only storage (4 bytes on 32-bit). +/// Codegen wraps constants in stateless lambdas so only a function pointer is needed. +/// Stateful lambdas (std::function) are rejected at compile time. template class TemplatableValue { - // For std::string, store pointer to heap-allocated string to keep union pointer-sized. - // For other types, store value inline. - static constexpr bool USE_HEAP_STORAGE = std::same_as; + public: + TemplatableValue() = default; + // Accept stateless lambdas (convertible to function pointer) + template TemplatableValue(F f) requires std::convertible_to : f_(f) {} + + // Reject stateful lambdas at compile time + template + TemplatableValue(F) requires std::invocable &&(!std::convertible_to) = delete; + + bool has_value() const { return this->f_ != nullptr; } + + T value(X... x) const { return this->f_ ? this->f_(x...) : T{}; } + + optional optional_value(X... x) const { + if (!this->f_) + return {}; + return this->f_(x...); + } + + T value_or(X... x, T default_value) const { return this->f_ ? this->f_(x...) : default_value; } + + protected: + T (*f_)(X...){nullptr}; +}; + +/// Specialization for std::string: supports VALUE, STATIC_STRING, FLASH_STRING, +/// stateless lambdas, and stateful lambdas (std::function). +template class TemplatableValue { public: TemplatableValue() : type_(NONE) {} - // For const char* when T is std::string: store pointer directly, no heap allocation - // String remains in flash and is only converted to std::string when value() is called - TemplatableValue(const char *str) requires std::same_as : type_(STATIC_STRING) { - this->static_str_ = str; - } + // For const char*: store pointer directly, no heap allocation. + // String remains in flash and is only converted to std::string when value() is called. + TemplatableValue(const char *str) : type_(STATIC_STRING) { this->static_str_ = str; } #ifdef USE_ESP8266 // On ESP8266, __FlashStringHelper* is a distinct type from const char*. // ESPHOME_F(s) expands to F(s) which returns __FlashStringHelper* pointing to PROGMEM. - // Store as FLASH_STRING — value()/is_empty()/ref_or_copy_to() use _P functions - // to access the PROGMEM pointer safely. - TemplatableValue(const __FlashStringHelper *str) requires std::same_as : type_(FLASH_STRING) { + // Store as FLASH_STRING — value()/is_empty()/ref_or_copy_to() use _P functions. + TemplatableValue(const __FlashStringHelper *str) : type_(FLASH_STRING) { this->static_str_ = reinterpret_cast(str); } #endif template TemplatableValue(F value) requires(!std::invocable) : type_(VALUE) { - if constexpr (USE_HEAP_STORAGE) { - this->value_ = new T(std::move(value)); - } else { - new (&this->value_) T(std::move(value)); - } + this->value_ = new std::string(std::move(value)); } // For stateless lambdas (convertible to function pointer): use function pointer template - TemplatableValue(F f) requires std::invocable && std::convertible_to + TemplatableValue(F f) requires std::invocable && std::convertible_to : type_(STATELESS_LAMBDA) { - this->stateless_f_ = f; // Implicit conversion to function pointer + this->stateless_f_ = f; } // For stateful lambdas (not convertible to function pointer): use std::function template - TemplatableValue(F f) requires std::invocable &&(!std::convertible_to) : type_(LAMBDA) { - this->f_ = new std::function(std::move(f)); + TemplatableValue(F f) requires std::invocable &&(!std::convertible_to) + : type_(LAMBDA) { + this->f_ = new std::function(std::move(f)); } - // Copy constructor TemplatableValue(const TemplatableValue &other) : type_(other.type_) { if (this->type_ == VALUE) { - if constexpr (USE_HEAP_STORAGE) { - this->value_ = new T(*other.value_); - } else { - new (&this->value_) T(other.value_); - } + this->value_ = new std::string(*other.value_); } else if (this->type_ == LAMBDA) { - this->f_ = new std::function(*other.f_); + this->f_ = new std::function(*other.f_); } else if (this->type_ == STATELESS_LAMBDA) { this->stateless_f_ = other.stateless_f_; } else if (this->type_ == STATIC_STRING || this->type_ == FLASH_STRING) { @@ -105,15 +122,10 @@ template class TemplatableValue { } } - // Move constructor TemplatableValue(TemplatableValue &&other) noexcept : type_(other.type_) { if (this->type_ == VALUE) { - if constexpr (USE_HEAP_STORAGE) { - this->value_ = other.value_; - other.value_ = nullptr; - } else { - new (&this->value_) T(std::move(other.value_)); - } + this->value_ = other.value_; + other.value_ = nullptr; } else if (this->type_ == LAMBDA) { this->f_ = other.f_; other.f_ = nullptr; @@ -125,7 +137,6 @@ template class TemplatableValue { other.type_ = NONE; } - // Assignment operators TemplatableValue &operator=(const TemplatableValue &other) { if (this != &other) { this->~TemplatableValue(); @@ -144,82 +155,58 @@ template class TemplatableValue { ~TemplatableValue() { if (this->type_ == VALUE) { - if constexpr (USE_HEAP_STORAGE) { - delete this->value_; - } else { - this->value_.~T(); - } + delete this->value_; } else if (this->type_ == LAMBDA) { delete this->f_; } - // STATELESS_LAMBDA/STATIC_STRING/FLASH_STRING/NONE: no cleanup needed (pointers, not heap-allocated) } bool has_value() const { return this->type_ != NONE; } - T value(X... x) const { + std::string value(X... x) const { switch (this->type_) { case STATELESS_LAMBDA: - return this->stateless_f_(x...); // Direct function pointer call + return this->stateless_f_(x...); case LAMBDA: - return (*this->f_)(x...); // std::function call + return (*this->f_)(x...); case VALUE: - if constexpr (USE_HEAP_STORAGE) { - return *this->value_; - } else { - return this->value_; - } + return *this->value_; case STATIC_STRING: - // if constexpr required: code must compile for all T, but STATIC_STRING - // can only be set when T is std::string (enforced by constructor constraint) - if constexpr (std::same_as) { - return std::string(this->static_str_); - } - __builtin_unreachable(); + return std::string(this->static_str_); #ifdef USE_ESP8266 - case FLASH_STRING: - // PROGMEM pointer — must use _P functions to access on ESP8266 - if constexpr (std::same_as) { - size_t len = strlen_P(this->static_str_); - std::string result(len, '\0'); - memcpy_P(result.data(), this->static_str_, len); - return result; - } - __builtin_unreachable(); + case FLASH_STRING: { + size_t len = strlen_P(this->static_str_); + std::string result(len, '\0'); + memcpy_P(result.data(), this->static_str_, len); + return result; + } #endif case NONE: default: - return T{}; + return {}; } } - optional optional_value(X... x) { - if (!this->has_value()) { + optional optional_value(X... x) { + if (!this->has_value()) return {}; - } return this->value(x...); } - T value_or(X... x, T default_value) { - if (!this->has_value()) { + std::string value_or(X... x, std::string default_value) { + if (!this->has_value()) return default_value; - } return this->value(x...); } - /// Check if this holds a static string (const char* stored without allocation) - /// The pointer is always directly readable (RAM or flash-mapped). - /// Returns false for FLASH_STRING (PROGMEM on ESP8266, requires _P functions). + /// Check if this holds a static string (const char* stored without allocation). bool is_static_string() const { return this->type_ == STATIC_STRING; } - /// Get the static string pointer (only valid if is_static_string() returns true) - /// The pointer is always directly readable — FLASH_STRING uses a separate type. + /// Get the static string pointer (only valid if is_static_string() returns true). const char *get_static_string() const { return this->static_str_; } - /// Check if the string value is empty without allocating (for std::string specialization). - /// For NONE, returns true. For STATIC_STRING/VALUE, checks without allocation. - /// For LAMBDA/STATELESS_LAMBDA, must call value() which may allocate. - bool is_empty() const requires std::same_as { + /// Check if the string value is empty without allocating. + bool is_empty() const { switch (this->type_) { case NONE: return true; @@ -227,25 +214,18 @@ template class TemplatableValue { return this->static_str_ == nullptr || this->static_str_[0] == '\0'; #ifdef USE_ESP8266 case FLASH_STRING: - // PROGMEM pointer — must use progmem_read_byte on ESP8266 return this->static_str_ == nullptr || progmem_read_byte(reinterpret_cast(this->static_str_)) == '\0'; #endif case VALUE: return this->value_->empty(); - default: // LAMBDA/STATELESS_LAMBDA - must call value() + default: return this->value().empty(); } } - /// Get a StringRef to the string value without heap allocation when possible. - /// For STATIC_STRING/VALUE, returns reference to existing data (no allocation). - /// For FLASH_STRING (ESP8266 PROGMEM), copies to provided buffer via _P functions. - /// For LAMBDA/STATELESS_LAMBDA, calls value(), copies to provided buffer, returns ref to buffer. - /// @param lambda_buf Buffer used only for copy cases (must remain valid while StringRef is used). - /// @param lambda_buf_size Size of the buffer. - /// @return StringRef pointing to the string data. - StringRef ref_or_copy_to(char *lambda_buf, size_t lambda_buf_size) const requires std::same_as { + /// Get a StringRef without heap allocation when possible. + StringRef ref_or_copy_to(char *lambda_buf, size_t lambda_buf_size) const { switch (this->type_) { case NONE: return StringRef(); @@ -258,7 +238,6 @@ template class TemplatableValue { if (this->static_str_ == nullptr) return StringRef(); { - // PROGMEM pointer — copy to buffer via _P functions size_t len = strlen_P(this->static_str_); size_t copy_len = std::min(len, lambda_buf_size - 1); memcpy_P(lambda_buf, this->static_str_, copy_len); @@ -268,7 +247,7 @@ template class TemplatableValue { #endif case VALUE: return StringRef(this->value_->data(), this->value_->size()); - default: { // LAMBDA/STATELESS_LAMBDA - must call value() and copy + default: { std::string result = this->value(); size_t copy_len = std::min(result.size(), lambda_buf_size - 1); memcpy(lambda_buf, result.data(), copy_len); @@ -278,22 +257,20 @@ template class TemplatableValue { } } - protected : enum : uint8_t { - NONE, - VALUE, - LAMBDA, - STATELESS_LAMBDA, - STATIC_STRING, // For const char* when T is std::string - avoids heap allocation - FLASH_STRING, // PROGMEM pointer on ESP8266; never set on other platforms - } type_; - // For std::string, use heap pointer to minimize union size (4 bytes vs 12+). - // For other types, store value inline as before. - using ValueStorage = std::conditional_t; + protected: + enum : uint8_t { + NONE, + VALUE, + LAMBDA, + STATELESS_LAMBDA, + STATIC_STRING, + FLASH_STRING, + } type_; union { - ValueStorage value_; // T for inline storage, T* for heap storage - std::function *f_; - T (*stateless_f_)(X...); - const char *static_str_; // For STATIC_STRING and FLASH_STRING types + std::string *value_; + std::function *f_; + std::string (*stateless_f_)(X...); + const char *static_str_; }; }; diff --git a/esphome/cpp_generator.py b/esphome/cpp_generator.py index a8efe96cce..55f49fcfa7 100644 --- a/esphome/cpp_generator.py +++ b/esphome/cpp_generator.py @@ -823,7 +823,9 @@ async def templatable( """Generate code for a templatable config option. If `value` is a templated value, the lambda expression is returned. - Otherwise the value is returned as-is (optionally process with to_exp). + For std::string output, constants are returned as-is (with PROGMEM wrapping). + For all other output types, constants are wrapped in stateless lambdas + so that TemplatableValue can store them as function pointers. :param value: The value to process. :param args: The arguments for the lambda expression. @@ -833,20 +835,28 @@ async def templatable( """ if is_template(value): return await process_lambda(value, args, return_type=output_type) - if to_exp is None: + if to_exp is not None: + value = to_exp[value] if isinstance(to_exp, dict) else to_exp(value) + elif isinstance(value, str) and output_type is not None: # Automatically wrap static strings in ESPHOME_F() for PROGMEM storage on ESP8266. # On other platforms ESPHOME_F() is a no-op returning const char*. - # Lazy import to avoid circular dependency (cpp_generator <-> cpp_types). - # Identity check (is) avoids brittle string comparison. - if isinstance(value, str) and output_type is not None: - from esphome.cpp_types import std_string + from esphome.cpp_types import std_string - if output_type is std_string: - return FlashStringLiteral(value) - return value - if isinstance(to_exp, dict): - return to_exp[value] - return to_exp(value) + if output_type is std_string: + return FlashStringLiteral(value) + # For non-string types, wrap constants in stateless lambdas so that + # TemplatableValue stores them as function pointers (4 bytes vs 8). + if output_type is not None: + from esphome.cpp_types import std_string + + if output_type is not std_string: + return LambdaExpression( + f"return {safe_exp(value)};", + args, + capture="", + return_type=output_type, + ) + return value class MockObj(Expression): diff --git a/tests/unit_tests/test_cpp_generator.py b/tests/unit_tests/test_cpp_generator.py index bdc31cdef8..aef6d056b2 100644 --- a/tests/unit_tests/test_cpp_generator.py +++ b/tests/unit_tests/test_cpp_generator.py @@ -669,11 +669,10 @@ async def test_templatable__int_with_std_string() -> None: @pytest.mark.asyncio async def test_templatable__string_with_non_string_output_type() -> None: - """Static string with non-std::string output_type returns raw string.""" + """Static string with non-std::string output_type returns stateless lambda.""" result = await cg.templatable("hello", [], ct.bool_) - assert isinstance(result, str) - assert result == "hello" + assert isinstance(result, cg.LambdaExpression) @pytest.mark.asyncio From 1ba33e485a8f68d4997509e4beb5ab85f48e8b13 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 7 Apr 2026 13:21:50 -1000 Subject: [PATCH 02/20] [mdns] Wrap port assignments in stateless lambdas for TemplatableValue TemplatableValue no longer accepts raw integer assignment. Wrap USE_WEBSERVER_PORT, USE_SENDSPIN_PORT, and get_port() calls in stateless lambdas. --- esphome/components/mdns/mdns_component.cpp | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/esphome/components/mdns/mdns_component.cpp b/esphome/components/mdns/mdns_component.cpp index 342a6e6c64..b5a70449cf 100644 --- a/esphome/components/mdns/mdns_component.cpp +++ b/esphome/components/mdns/mdns_component.cpp @@ -57,7 +57,7 @@ void MDNSComponent::compile_records_(StaticVectorget_port(); + service.port = []() -> uint16_t { return api::global_api_server->get_port(); }; const auto &friendly_name = App.get_friendly_name(); bool friendly_name_empty = friendly_name.empty(); @@ -151,7 +151,7 @@ void MDNSComponent::compile_records_(StaticVector uint16_t { return USE_WEBSERVER_PORT; }; #endif #ifdef USE_SENDSPIN @@ -162,7 +162,7 @@ void MDNSComponent::compile_records_(StaticVector uint16_t { return USE_SENDSPIN_PORT; }; sendspin_service.txt_records = {{MDNS_STR(TXT_SENDSPIN_PATH), MDNS_STR(VALUE_SENDSPIN_PATH)}}; #endif @@ -172,7 +172,7 @@ void MDNSComponent::compile_records_(StaticVector uint16_t { return USE_WEBSERVER_PORT; }; #endif #if !defined(USE_API) && !defined(USE_PROMETHEUS) && !defined(USE_SENDSPIN) && !defined(USE_WEBSERVER) && \ @@ -185,7 +185,7 @@ void MDNSComponent::compile_records_(StaticVector uint16_t { return USE_WEBSERVER_PORT; }; fallback_service.txt_records = {{MDNS_STR(TXT_VERSION), MDNS_STR(VALUE_VERSION)}}; #endif } From 5e2187581a6d0eb81d1142411a359f070e8a327b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 7 Apr 2026 13:26:21 -1000 Subject: [PATCH 03/20] [sensor] Wrap filter constants in lambdas for TemplatableValue in benchmarks --- tests/benchmarks/components/sensor/bench_sensor_filter.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/benchmarks/components/sensor/bench_sensor_filter.cpp b/tests/benchmarks/components/sensor/bench_sensor_filter.cpp index e4aa397690..e6dc783567 100644 --- a/tests/benchmarks/components/sensor/bench_sensor_filter.cpp +++ b/tests/benchmarks/components/sensor/bench_sensor_filter.cpp @@ -56,8 +56,8 @@ static void SensorFilter_Chain3(benchmark::State &state) { Sensor sensor; sensor.add_filters({ - new OffsetFilter(1.0f), - new MultiplyFilter(2.0f), + new OffsetFilter([]() -> float { return 1.0f; }), + new MultiplyFilter([]() -> float { return 2.0f; }), new SlidingWindowMovingAverageFilter(5, 1, 1), }); From dc38cf0ced3ce01e4042eb597c288b68b4afb62e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 7 Apr 2026 13:34:11 -1000 Subject: [PATCH 04/20] Revert "[mdns] Wrap port assignments in stateless lambdas for TemplatableValue" This reverts commit 1ba33e485a8f68d4997509e4beb5ab85f48e8b13. --- esphome/components/mdns/mdns_component.cpp | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/esphome/components/mdns/mdns_component.cpp b/esphome/components/mdns/mdns_component.cpp index b5a70449cf..342a6e6c64 100644 --- a/esphome/components/mdns/mdns_component.cpp +++ b/esphome/components/mdns/mdns_component.cpp @@ -57,7 +57,7 @@ void MDNSComponent::compile_records_(StaticVector uint16_t { return api::global_api_server->get_port(); }; + service.port = api::global_api_server->get_port(); const auto &friendly_name = App.get_friendly_name(); bool friendly_name_empty = friendly_name.empty(); @@ -151,7 +151,7 @@ void MDNSComponent::compile_records_(StaticVector uint16_t { return USE_WEBSERVER_PORT; }; + prom_service.port = USE_WEBSERVER_PORT; #endif #ifdef USE_SENDSPIN @@ -162,7 +162,7 @@ void MDNSComponent::compile_records_(StaticVector uint16_t { return USE_SENDSPIN_PORT; }; + sendspin_service.port = USE_SENDSPIN_PORT; sendspin_service.txt_records = {{MDNS_STR(TXT_SENDSPIN_PATH), MDNS_STR(VALUE_SENDSPIN_PATH)}}; #endif @@ -172,7 +172,7 @@ void MDNSComponent::compile_records_(StaticVector uint16_t { return USE_WEBSERVER_PORT; }; + web_service.port = USE_WEBSERVER_PORT; #endif #if !defined(USE_API) && !defined(USE_PROMETHEUS) && !defined(USE_SENDSPIN) && !defined(USE_WEBSERVER) && \ @@ -185,7 +185,7 @@ void MDNSComponent::compile_records_(StaticVector uint16_t { return USE_WEBSERVER_PORT; }; + fallback_service.port = USE_WEBSERVER_PORT; fallback_service.txt_records = {{MDNS_STR(TXT_VERSION), MDNS_STR(VALUE_VERSION)}}; #endif } From 8cd78441a6d15b3482ff231b2a7dde9b57a9f27e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 7 Apr 2026 13:34:11 -1000 Subject: [PATCH 05/20] Revert "[sensor] Wrap filter constants in lambdas for TemplatableValue in benchmarks" This reverts commit 5e2187581a6d0eb81d1142411a359f070e8a327b. --- tests/benchmarks/components/sensor/bench_sensor_filter.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/benchmarks/components/sensor/bench_sensor_filter.cpp b/tests/benchmarks/components/sensor/bench_sensor_filter.cpp index e6dc783567..e4aa397690 100644 --- a/tests/benchmarks/components/sensor/bench_sensor_filter.cpp +++ b/tests/benchmarks/components/sensor/bench_sensor_filter.cpp @@ -56,8 +56,8 @@ static void SensorFilter_Chain3(benchmark::State &state) { Sensor sensor; sensor.add_filters({ - new OffsetFilter([]() -> float { return 1.0f; }), - new MultiplyFilter([]() -> float { return 2.0f; }), + new OffsetFilter(1.0f), + new MultiplyFilter(2.0f), new SlidingWindowMovingAverageFilter(5, 1, 1), }); From 03677db79a5f07a37de478e28587f1da980dc40a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 7 Apr 2026 13:37:18 -1000 Subject: [PATCH 06/20] [core] Add TemplatableFn and TemplatableValue (value+fn) dual storage - TemplatableFn: 4-byte function-pointer-only storage, used by TEMPLATABLE_VALUE macro for codegen-managed fields - TemplatableValue: 8-byte value-or-function-pointer storage, backward compatible with raw constant init and assignment - Revert api/user_services.h to original (uses TemplatableValue) - Optimize sensor/number min_/max_ to TemplatableFn - Optimize mdns port to TemplatableFn with lambda wrappers --- esphome/components/api/user_services.h | 2 +- esphome/components/mdns/mdns_component.cpp | 10 +- esphome/components/mdns/mdns_component.h | 2 +- esphome/components/number/automation.h | 4 +- esphome/components/sensor/automation.h | 4 +- esphome/core/automation.h | 134 ++++++++++++++++++--- 6 files changed, 128 insertions(+), 28 deletions(-) diff --git a/esphome/components/api/user_services.h b/esphome/components/api/user_services.h index 1f35be7ef9..d1b8a6ef0d 100644 --- a/esphome/components/api/user_services.h +++ b/esphome/components/api/user_services.h @@ -275,7 +275,7 @@ template class APIRespondAction : public Action { protected: APIServer *parent_; - TemplatableValue success_{[](Ts...) -> bool { return true; }}; + TemplatableValue success_{true}; TemplatableValue error_message_{""}; #ifdef USE_API_USER_DEFINED_ACTION_RESPONSES_JSON std::function json_builder_; diff --git a/esphome/components/mdns/mdns_component.cpp b/esphome/components/mdns/mdns_component.cpp index 342a6e6c64..b5a70449cf 100644 --- a/esphome/components/mdns/mdns_component.cpp +++ b/esphome/components/mdns/mdns_component.cpp @@ -57,7 +57,7 @@ void MDNSComponent::compile_records_(StaticVectorget_port(); + service.port = []() -> uint16_t { return api::global_api_server->get_port(); }; const auto &friendly_name = App.get_friendly_name(); bool friendly_name_empty = friendly_name.empty(); @@ -151,7 +151,7 @@ void MDNSComponent::compile_records_(StaticVector uint16_t { return USE_WEBSERVER_PORT; }; #endif #ifdef USE_SENDSPIN @@ -162,7 +162,7 @@ void MDNSComponent::compile_records_(StaticVector uint16_t { return USE_SENDSPIN_PORT; }; sendspin_service.txt_records = {{MDNS_STR(TXT_SENDSPIN_PATH), MDNS_STR(VALUE_SENDSPIN_PATH)}}; #endif @@ -172,7 +172,7 @@ void MDNSComponent::compile_records_(StaticVector uint16_t { return USE_WEBSERVER_PORT; }; #endif #if !defined(USE_API) && !defined(USE_PROMETHEUS) && !defined(USE_SENDSPIN) && !defined(USE_WEBSERVER) && \ @@ -185,7 +185,7 @@ void MDNSComponent::compile_records_(StaticVector uint16_t { return USE_WEBSERVER_PORT; }; fallback_service.txt_records = {{MDNS_STR(TXT_VERSION), MDNS_STR(VALUE_VERSION)}}; #endif } diff --git a/esphome/components/mdns/mdns_component.h b/esphome/components/mdns/mdns_component.h index 47cad4bf71..adf88a9cf1 100644 --- a/esphome/components/mdns/mdns_component.h +++ b/esphome/components/mdns/mdns_component.h @@ -36,7 +36,7 @@ struct MDNSService { // second label indicating protocol _including_ underscore character prefix // as defined in RFC6763 Section 7, like "_tcp" or "_udp" const MDNSString *proto; - TemplatableValue port; + TemplatableFn port; FixedVector txt_records; }; diff --git a/esphome/components/number/automation.h b/esphome/components/number/automation.h index 834998b32b..2843aa6bf5 100644 --- a/esphome/components/number/automation.h +++ b/esphome/components/number/automation.h @@ -63,8 +63,8 @@ class ValueRangeTrigger : public Trigger, public Component { Number *parent_; ESPPreferenceObject rtc_; bool previous_in_range_{false}; - TemplatableValue min_{[](float) -> float { return NAN; }}; // NAN = no bound - TemplatableValue max_{[](float) -> float { return NAN; }}; // NAN = no bound + TemplatableFn min_{[](float) -> float { return NAN; }}; + TemplatableFn max_{[](float) -> float { return NAN; }}; }; template class NumberInRangeCondition : public Condition { diff --git a/esphome/components/sensor/automation.h b/esphome/components/sensor/automation.h index 989ee2317b..37578f5320 100644 --- a/esphome/components/sensor/automation.h +++ b/esphome/components/sensor/automation.h @@ -79,8 +79,8 @@ class ValueRangeTrigger : public Trigger, public Component { Sensor *parent_; ESPPreferenceObject rtc_; bool previous_in_range_{false}; - TemplatableValue min_{[](float) -> float { return NAN; }}; - TemplatableValue max_{[](float) -> float { return NAN; }}; + TemplatableFn min_{[](float) -> float { return NAN; }}; + TemplatableFn max_{[](float) -> float { return NAN; }}; }; template class SensorInRangeCondition : public Condition { diff --git a/esphome/core/automation.h b/esphome/core/automation.h index 7879478f5b..a89a16aa0a 100644 --- a/esphome/core/automation.h +++ b/esphome/core/automation.h @@ -34,28 +34,17 @@ template struct gens<0, S...> { using type = seq; }; #endif // NOLINTEND(readability-identifier-naming) -#define TEMPLATABLE_VALUE_(type, name) \ - protected: \ - TemplatableValue name##_{}; \ -\ - public: \ - template void set_##name(V name) { this->name##_ = name; } - -#define TEMPLATABLE_VALUE(type, name) TEMPLATABLE_VALUE_(type, name) - -/// Primary template: function-pointer-only storage (4 bytes on 32-bit). +/// Function-pointer-only templatable storage (4 bytes on 32-bit). +/// Used by the TEMPLATABLE_VALUE macro for codegen-managed fields. /// Codegen wraps constants in stateless lambdas so only a function pointer is needed. -/// Stateful lambdas (std::function) are rejected at compile time. -template class TemplatableValue { +template class TemplatableFn { public: - TemplatableValue() = default; + TemplatableFn() = default; - // Accept stateless lambdas (convertible to function pointer) - template TemplatableValue(F f) requires std::convertible_to : f_(f) {} + template TemplatableFn(F f) requires std::convertible_to : f_(f) {} - // Reject stateful lambdas at compile time template - TemplatableValue(F) requires std::invocable &&(!std::convertible_to) = delete; + TemplatableFn(F) requires std::invocable &&(!std::convertible_to) = delete; bool has_value() const { return this->f_ != nullptr; } @@ -73,6 +62,117 @@ template class TemplatableValue { T (*f_)(X...){nullptr}; }; +#define TEMPLATABLE_VALUE_(type, name) \ + protected: \ + TemplatableFn name##_{}; \ +\ + public: \ + template void set_##name(V name) { this->name##_ = name; } + +#define TEMPLATABLE_VALUE(type, name) TEMPLATABLE_VALUE_(type, name) + +/// Primary TemplatableValue: stores either a constant value or a function pointer. +/// No std::function, no string-specific paths. 8 bytes on 32-bit. +/// Accepts raw constants for backward compatibility with direct C++ usage. +template class TemplatableValue { + public: + TemplatableValue() = default; + + // Accept raw constants + template TemplatableValue(V value) requires(!std::invocable) : tag_(VALUE) { + new (&this->value_) T(static_cast(std::move(value))); + } + + // Accept stateless lambdas (convertible to function pointer) + template TemplatableValue(F f) requires std::convertible_to : tag_(FN) { this->f_ = f; } + + // Reject stateful lambdas at compile time + template + TemplatableValue(F) requires std::invocable &&(!std::convertible_to) = delete; + + TemplatableValue(const TemplatableValue &other) : tag_(other.tag_) { + if (this->tag_ == VALUE) { + new (&this->value_) T(other.value_); + } else if (this->tag_ == FN) { + this->f_ = other.f_; + } + } + + TemplatableValue(TemplatableValue &&other) noexcept : tag_(other.tag_) { + if (this->tag_ == VALUE) { + new (&this->value_) T(std::move(other.value_)); + } else if (this->tag_ == FN) { + this->f_ = other.f_; + } + other.tag_ = NONE; + } + + TemplatableValue &operator=(const TemplatableValue &other) { + if (this != &other) { + this->destroy_(); + this->tag_ = other.tag_; + if (this->tag_ == VALUE) { + new (&this->value_) T(other.value_); + } else if (this->tag_ == FN) { + this->f_ = other.f_; + } + } + return *this; + } + + TemplatableValue &operator=(TemplatableValue &&other) noexcept { + if (this != &other) { + this->destroy_(); + this->tag_ = other.tag_; + if (this->tag_ == VALUE) { + new (&this->value_) T(std::move(other.value_)); + } else if (this->tag_ == FN) { + this->f_ = other.f_; + } + other.tag_ = NONE; + } + return *this; + } + + ~TemplatableValue() { this->destroy_(); } + + bool has_value() const { return this->tag_ != NONE; } + + T value(X... x) const { + if (this->tag_ == FN) + return this->f_(x...); + if (this->tag_ == VALUE) + return this->value_; + return T{}; + } + + optional optional_value(X... x) const { + if (this->tag_ == NONE) + return {}; + return this->value(x...); + } + + T value_or(X... x, T default_value) const { + if (this->tag_ == NONE) + return default_value; + return this->value(x...); + } + + protected: + void destroy_() { + if constexpr (!std::is_trivially_destructible_v) { + if (this->tag_ == VALUE) + this->value_.~T(); + } + } + + enum Tag : uint8_t { NONE, VALUE, FN } tag_{NONE}; + union { + T value_; + T (*f_)(X...); + }; +}; + /// Specialization for std::string: supports VALUE, STATIC_STRING, FLASH_STRING, /// stateless lambdas, and stateful lambdas (std::function). template class TemplatableValue { From a9e9064aa373458a618c18212cceb33446d03019 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 7 Apr 2026 13:42:00 -1000 Subject: [PATCH 07/20] convert a few more --- .../analog_threshold_binary_sensor.h | 4 +-- esphome/components/api/user_services.h | 2 +- esphome/components/binary_sensor/filter.h | 12 ++++----- esphome/components/mdns/mdns_component.cpp | 2 +- esphome/components/mdns/mdns_esp32.cpp | 2 +- esphome/components/mdns/mdns_esp8266.cpp | 2 +- esphome/components/mdns/mdns_libretiny.cpp | 2 +- esphome/components/mdns/mdns_rp2040.cpp | 2 +- esphome/components/sensor/filter.cpp | 8 +++--- esphome/components/sensor/filter.h | 26 +++++++++---------- 10 files changed, 31 insertions(+), 31 deletions(-) diff --git a/esphome/components/analog_threshold/analog_threshold_binary_sensor.h b/esphome/components/analog_threshold/analog_threshold_binary_sensor.h index dd70768105..55a822b9b0 100644 --- a/esphome/components/analog_threshold/analog_threshold_binary_sensor.h +++ b/esphome/components/analog_threshold/analog_threshold_binary_sensor.h @@ -19,8 +19,8 @@ class AnalogThresholdBinarySensor : public Component, public binary_sensor::Bina protected: sensor::Sensor *sensor_{nullptr}; - TemplatableValue upper_threshold_{}; - TemplatableValue lower_threshold_{}; + TemplatableFn upper_threshold_{}; + TemplatableFn lower_threshold_{}; bool raw_state_{false}; // Pre-filter state for hysteresis logic }; diff --git a/esphome/components/api/user_services.h b/esphome/components/api/user_services.h index d1b8a6ef0d..29eadda927 100644 --- a/esphome/components/api/user_services.h +++ b/esphome/components/api/user_services.h @@ -275,7 +275,7 @@ template class APIRespondAction : public Action { protected: APIServer *parent_; - TemplatableValue success_{true}; + TemplatableFn success_{[](Ts...) -> bool { return true; }}; TemplatableValue error_message_{""}; #ifdef USE_API_USER_DEFINED_ACTION_RESPONSES_JSON std::function json_builder_; diff --git a/esphome/components/binary_sensor/filter.h b/esphome/components/binary_sensor/filter.h index 37c6bf0092..2e45554f81 100644 --- a/esphome/components/binary_sensor/filter.h +++ b/esphome/components/binary_sensor/filter.h @@ -36,7 +36,7 @@ class TimeoutFilter : public Filter, public Component { template void set_timeout_value(T timeout) { this->timeout_delay_ = timeout; } protected: - TemplatableValue timeout_delay_{}; + TemplatableFn timeout_delay_{}; }; class DelayedOnOffFilter final : public Filter, public Component { @@ -49,8 +49,8 @@ class DelayedOnOffFilter final : public Filter, public Component { template void set_off_delay(T delay) { this->off_delay_ = delay; } protected: - TemplatableValue on_delay_{}; - TemplatableValue off_delay_{}; + TemplatableFn on_delay_{}; + TemplatableFn off_delay_{}; }; class DelayedOnFilter : public Filter, public Component { @@ -62,7 +62,7 @@ class DelayedOnFilter : public Filter, public Component { template void set_delay(T delay) { this->delay_ = delay; } protected: - TemplatableValue delay_{}; + TemplatableFn delay_{}; }; class DelayedOffFilter : public Filter, public Component { @@ -74,7 +74,7 @@ class DelayedOffFilter : public Filter, public Component { template void set_delay(T delay) { this->delay_ = delay; } protected: - TemplatableValue delay_{}; + TemplatableFn delay_{}; }; class InvertFilter : public Filter { @@ -155,7 +155,7 @@ class SettleFilter : public Filter, public Component { template void set_delay(T delay) { this->delay_ = delay; } protected: - TemplatableValue delay_{}; + TemplatableFn delay_{}; bool steady_{true}; }; diff --git a/esphome/components/mdns/mdns_component.cpp b/esphome/components/mdns/mdns_component.cpp index b5a70449cf..e05373ac5d 100644 --- a/esphome/components/mdns/mdns_component.cpp +++ b/esphome/components/mdns/mdns_component.cpp @@ -199,7 +199,7 @@ void MDNSComponent::dump_config() { ESP_LOGV(TAG, " Services:"); for (const auto &service : this->services_) { ESP_LOGV(TAG, " - %s, %s, %d", MDNS_STR_ARG(service.service_type), MDNS_STR_ARG(service.proto), - const_cast &>(service.port).value()); + service.port.value()); for (const auto &record : service.txt_records) { ESP_LOGV(TAG, " TXT: %s = %s", MDNS_STR_ARG(record.key), MDNS_STR_ARG(record.value)); } diff --git a/esphome/components/mdns/mdns_esp32.cpp b/esphome/components/mdns/mdns_esp32.cpp index 3e997402bc..17000a2bd7 100644 --- a/esphome/components/mdns/mdns_esp32.cpp +++ b/esphome/components/mdns/mdns_esp32.cpp @@ -37,7 +37,7 @@ static void register_esp32(MDNSComponent *comp, StaticVector &>(service.port).value(); + uint16_t port = service.port.value(); err = mdns_service_add(nullptr, MDNS_STR_ARG(service.service_type), MDNS_STR_ARG(service.proto), port, txt_records.get(), service.txt_records.size()); diff --git a/esphome/components/mdns/mdns_esp8266.cpp b/esphome/components/mdns/mdns_esp8266.cpp index 295a408cbd..70c614f8d3 100644 --- a/esphome/components/mdns/mdns_esp8266.cpp +++ b/esphome/components/mdns/mdns_esp8266.cpp @@ -27,7 +27,7 @@ static void register_esp8266(MDNSComponent *, StaticVector &>(service.port).value(); + uint16_t port = service.port.value(); MDNS.addService(FPSTR(service_type), FPSTR(proto), port); for (const auto &record : service.txt_records) { MDNS.addServiceTxt(FPSTR(service_type), FPSTR(proto), FPSTR(MDNS_STR_ARG(record.key)), diff --git a/esphome/components/mdns/mdns_libretiny.cpp b/esphome/components/mdns/mdns_libretiny.cpp index 986099fa1f..a543a3809a 100644 --- a/esphome/components/mdns/mdns_libretiny.cpp +++ b/esphome/components/mdns/mdns_libretiny.cpp @@ -27,7 +27,7 @@ static void register_libretiny(MDNSComponent *, StaticVector &>(service.port).value(); + uint16_t port_ = service.port.value(); MDNS.addService(service_type, proto, port_); for (const auto &record : service.txt_records) { MDNS.addServiceTxt(service_type, proto, MDNS_STR_ARG(record.key), MDNS_STR_ARG(record.value)); diff --git a/esphome/components/mdns/mdns_rp2040.cpp b/esphome/components/mdns/mdns_rp2040.cpp index 88f707afd3..64b603030c 100644 --- a/esphome/components/mdns/mdns_rp2040.cpp +++ b/esphome/components/mdns/mdns_rp2040.cpp @@ -32,7 +32,7 @@ static void register_rp2040(MDNSComponent *, StaticVector &>(service.port).value(); + uint16_t port = service.port.value(); MDNS.addService(service_type, proto, port); for (const auto &record : service.txt_records) { MDNS.addServiceTxt(service_type, proto, MDNS_STR_ARG(record.key), MDNS_STR_ARG(record.value)); diff --git a/esphome/components/sensor/filter.cpp b/esphome/components/sensor/filter.cpp index 6a90a5af66..d1553e3cb5 100644 --- a/esphome/components/sensor/filter.cpp +++ b/esphome/components/sensor/filter.cpp @@ -213,17 +213,17 @@ optional LambdaFilter::new_value(float value) { } // OffsetFilter -OffsetFilter::OffsetFilter(TemplatableValue offset) : offset_(std::move(offset)) {} +OffsetFilter::OffsetFilter(TemplatableFn offset) : offset_(std::move(offset)) {} optional OffsetFilter::new_value(float value) { return value + this->offset_.value(); } // MultiplyFilter -MultiplyFilter::MultiplyFilter(TemplatableValue multiplier) : multiplier_(std::move(multiplier)) {} +MultiplyFilter::MultiplyFilter(TemplatableFn multiplier) : multiplier_(std::move(multiplier)) {} optional MultiplyFilter::new_value(float value) { return value * this->multiplier_.value(); } // ValueListFilter helper (non-template, shared by all ValueListFilter instantiations) -bool value_list_matches_any(Sensor *parent, float sensor_value, const TemplatableValue *values, size_t count) { +bool value_list_matches_any(Sensor *parent, float sensor_value, const TemplatableFn *values, size_t count) { int8_t accuracy = parent->get_accuracy_decimals(); float accuracy_mult = pow10_int(accuracy); float rounded_sensor = roundf(accuracy_mult * sensor_value); @@ -258,7 +258,7 @@ optional ThrottleFilter::new_value(float value) { } // ThrottleWithPriorityFilter helper (non-template, keeps App access in .cpp) -optional throttle_with_priority_new_value(Sensor *parent, float value, const TemplatableValue *values, +optional throttle_with_priority_new_value(Sensor *parent, float value, const TemplatableFn *values, size_t count, uint32_t &last_input, uint32_t min_time_between_inputs) { const uint32_t now = App.get_loop_component_start_time(); if (last_input == 0 || now - last_input >= min_time_between_inputs || diff --git a/esphome/components/sensor/filter.h b/esphome/components/sensor/filter.h index cb4abd154a..0dbbc33ab3 100644 --- a/esphome/components/sensor/filter.h +++ b/esphome/components/sensor/filter.h @@ -311,26 +311,26 @@ class StatelessLambdaFilter : public Filter { /// A simple filter that adds `offset` to each value it receives. class OffsetFilter : public Filter { public: - explicit OffsetFilter(TemplatableValue offset); + explicit OffsetFilter(TemplatableFn offset); optional new_value(float value) override; protected: - TemplatableValue offset_; + TemplatableFn offset_; }; /// A simple filter that multiplies to each value it receives by `multiplier`. class MultiplyFilter : public Filter { public: - explicit MultiplyFilter(TemplatableValue multiplier); + explicit MultiplyFilter(TemplatableFn multiplier); optional new_value(float value) override; protected: - TemplatableValue multiplier_; + TemplatableFn multiplier_; }; /// Non-template helper for value matching (implementation in filter.cpp) -bool value_list_matches_any(Sensor *parent, float sensor_value, const TemplatableValue *values, size_t count); +bool value_list_matches_any(Sensor *parent, float sensor_value, const TemplatableFn *values, size_t count); /** Base class for filters that compare sensor values against a fixed list of configured values. * @@ -342,7 +342,7 @@ bool value_list_matches_any(Sensor *parent, float sensor_value, const Templatabl */ template class ValueListFilter : public Filter { protected: - explicit ValueListFilter(std::initializer_list> values) { + explicit ValueListFilter(std::initializer_list> values) { init_array_from(this->values_, values); } @@ -351,13 +351,13 @@ template class ValueListFilter : public Filter { return value_list_matches_any(this->parent_, sensor_value, this->values_.data(), N); } - std::array, N> values_{}; + std::array, N> values_{}; }; /// A simple filter that only forwards the filter chain if it doesn't receive `value_to_filter_out`. template class FilterOutValueFilter : public ValueListFilter { public: - explicit FilterOutValueFilter(std::initializer_list> values_to_filter_out) + explicit FilterOutValueFilter(std::initializer_list> values_to_filter_out) : ValueListFilter(values_to_filter_out) {} optional new_value(float value) override { @@ -379,14 +379,14 @@ class ThrottleFilter : public Filter { }; /// Non-template helper for ThrottleWithPriorityFilter (implementation in filter.cpp) -optional throttle_with_priority_new_value(Sensor *parent, float value, const TemplatableValue *values, +optional throttle_with_priority_new_value(Sensor *parent, float value, const TemplatableFn *values, size_t count, uint32_t &last_input, uint32_t min_time_between_inputs); /// Same as 'throttle' but will immediately publish values contained in `value_to_prioritize`. template class ThrottleWithPriorityFilter : public ValueListFilter { public: explicit ThrottleWithPriorityFilter(uint32_t min_time_between_inputs, - std::initializer_list> prioritized_values) + std::initializer_list> prioritized_values) : ValueListFilter(prioritized_values), min_time_between_inputs_(min_time_between_inputs) {} optional new_value(float value) override { @@ -430,15 +430,15 @@ class TimeoutFilterLast : public TimeoutFilterBase { // Timeout filter with configured value - evaluates TemplatableValue after timeout class TimeoutFilterConfigured : public TimeoutFilterBase { public: - explicit TimeoutFilterConfigured(uint32_t time_period, const TemplatableValue &new_value) + explicit TimeoutFilterConfigured(uint32_t time_period, const TemplatableFn &new_value) : TimeoutFilterBase(time_period), value_(new_value) {} optional new_value(float value) override; protected: float get_output_value() override { return this->value_.value(); } - TemplatableValue value_; // 16 bytes (configured output value, can be lambda) - // Total: 8 (base) + 16 = 24 bytes + vtable ptr + Component overhead + TemplatableFn value_; // 4 bytes (configured output value, can be lambda) + // Total: 8 (base) + 4 = 12 bytes + vtable ptr + Component overhead }; class DebounceFilter : public Filter, public Component { From b53b6e92182fd906702f1ccd5e92c5e91d6d650b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 7 Apr 2026 13:42:29 -1000 Subject: [PATCH 08/20] convert a few more --- esphome/components/lvgl/lvgl_esphome.h | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/lvgl/lvgl_esphome.h b/esphome/components/lvgl/lvgl_esphome.h index 4a4c11d383..2caad9392a 100644 --- a/esphome/components/lvgl/lvgl_esphome.h +++ b/esphome/components/lvgl/lvgl_esphome.h @@ -270,10 +270,10 @@ class LvglComponent : public PollingComponent { class IdleTrigger : public Trigger<> { public: - explicit IdleTrigger(LvglComponent *parent, TemplatableValue timeout); + explicit IdleTrigger(LvglComponent *parent, TemplatableFn timeout); protected: - TemplatableValue timeout_; + TemplatableFn timeout_; bool is_idle_{}; }; From d78a3ab41bbf65f57b62b08fe694363dd1ebdd36 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 7 Apr 2026 13:42:35 -1000 Subject: [PATCH 09/20] convert a few more --- esphome/components/lvgl/lvgl_esphome.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/lvgl/lvgl_esphome.cpp b/esphome/components/lvgl/lvgl_esphome.cpp index 0ab49d0a10..2d27d2cd4d 100644 --- a/esphome/components/lvgl/lvgl_esphome.cpp +++ b/esphome/components/lvgl/lvgl_esphome.cpp @@ -317,7 +317,7 @@ void LvglComponent::flush_cb_(lv_display_t *disp_drv, const lv_area_t *area, uin lv_display_flush_ready(disp_drv); } -IdleTrigger::IdleTrigger(LvglComponent *parent, TemplatableValue timeout) : timeout_(std::move(timeout)) { +IdleTrigger::IdleTrigger(LvglComponent *parent, TemplatableFn timeout) : timeout_(std::move(timeout)) { parent->add_on_idle_callback([this](uint32_t idle_time) { if (!this->is_idle_ && idle_time > this->timeout_.value()) { this->is_idle_ = true; From be8449a39a6377a531ecd13829ddd60c460f8cc3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 7 Apr 2026 13:43:13 -1000 Subject: [PATCH 10/20] convert a few more --- esphome/components/script/script.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/script/script.h b/esphome/components/script/script.h index cd1a084f16..a0dffe26bf 100644 --- a/esphome/components/script/script.h +++ b/esphome/components/script/script.h @@ -211,7 +211,7 @@ template class ScriptExecuteAction, T public: ScriptExecuteAction(Script *script) : script_(script) {} - using Args = std::tuple...>; + using Args = std::tuple...>; template void set_args(F... x) { args_ = Args{x...}; } From 4f4f256ab329b291684c2fd06e3da843f1521883 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 7 Apr 2026 13:44:23 -1000 Subject: [PATCH 11/20] convert a few more --- esphome/components/http_request/http_request.h | 4 ++-- esphome/components/openthread/openthread.cpp | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/components/http_request/http_request.h b/esphome/components/http_request/http_request.h index 73dbda8694..ae73983bab 100644 --- a/esphome/components/http_request/http_request.h +++ b/esphome/components/http_request/http_request.h @@ -457,7 +457,7 @@ template class HttpRequestSendAction : public Action { #endif void init_request_headers(size_t count) { this->request_headers_.init(count); } - void add_request_header(const char *key, TemplatableValue value) { + void add_request_header(const char *key, TemplatableFn value) { this->request_headers_.push_back({key, value}); } @@ -560,7 +560,7 @@ template class HttpRequestSendAction : public Action { } } HttpRequestComponent *parent_; - FixedVector>> request_headers_{}; + FixedVector>> request_headers_{}; std::vector lower_case_collect_headers_{"content-type", "content-length"}; FixedVector>> json_{}; std::function json_func_{nullptr}; diff --git a/esphome/components/openthread/openthread.cpp b/esphome/components/openthread/openthread.cpp index 7c9a308303..21dad4f867 100644 --- a/esphome/components/openthread/openthread.cpp +++ b/esphome/components/openthread/openthread.cpp @@ -181,7 +181,7 @@ void OpenThreadSrpComponent::setup() { memcpy(string, host_name.c_str(), host_name_len); // Set port - entry->mService.mPort = const_cast &>(service.port).value(); + entry->mService.mPort = service.port.value(); otDnsTxtEntry *txt_entries = reinterpret_cast(this->pool_alloc_(sizeof(otDnsTxtEntry) * service.txt_records.size())); From 11934ab27fa81bad4bd4b408f2a9945d75fa807c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 7 Apr 2026 13:49:55 -1000 Subject: [PATCH 12/20] [sensor] Wrap benchmark filter constants in lambdas for TemplatableFn --- tests/benchmarks/components/sensor/bench_sensor_filter.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/benchmarks/components/sensor/bench_sensor_filter.cpp b/tests/benchmarks/components/sensor/bench_sensor_filter.cpp index e4aa397690..e6dc783567 100644 --- a/tests/benchmarks/components/sensor/bench_sensor_filter.cpp +++ b/tests/benchmarks/components/sensor/bench_sensor_filter.cpp @@ -56,8 +56,8 @@ static void SensorFilter_Chain3(benchmark::State &state) { Sensor sensor; sensor.add_filters({ - new OffsetFilter(1.0f), - new MultiplyFilter(2.0f), + new OffsetFilter([]() -> float { return 1.0f; }), + new MultiplyFilter([]() -> float { return 2.0f; }), new SlidingWindowMovingAverageFilter(5, 1, 1), }); From d0754d14664b10404406c61bd20e20506fb1f0a4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 7 Apr 2026 13:55:12 -1000 Subject: [PATCH 13/20] [core] Fix TemplatableValue move: destroy moved-from value for non-trivial T --- esphome/core/automation.h | 2 ++ 1 file changed, 2 insertions(+) diff --git a/esphome/core/automation.h b/esphome/core/automation.h index a89a16aa0a..af730c3545 100644 --- a/esphome/core/automation.h +++ b/esphome/core/automation.h @@ -101,6 +101,7 @@ template class TemplatableValue { TemplatableValue(TemplatableValue &&other) noexcept : tag_(other.tag_) { if (this->tag_ == VALUE) { new (&this->value_) T(std::move(other.value_)); + other.destroy_(); } else if (this->tag_ == FN) { this->f_ = other.f_; } @@ -126,6 +127,7 @@ template class TemplatableValue { this->tag_ = other.tag_; if (this->tag_ == VALUE) { new (&this->value_) T(std::move(other.value_)); + other.destroy_(); } else if (this->tag_ == FN) { this->f_ = other.f_; } From 29a6361df37b8e8f0742e5da884abd049bcfa247 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 7 Apr 2026 13:56:46 -1000 Subject: [PATCH 14/20] [mdns] Wrap raw port values in lambda in mdns_service() public API --- esphome/components/mdns/__init__.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/esphome/components/mdns/__init__.py b/esphome/components/mdns/__init__.py index 0d535d6970..6f08887db8 100644 --- a/esphome/components/mdns/__init__.py +++ b/esphome/components/mdns/__init__.py @@ -131,6 +131,13 @@ def mdns_service( Returns: A StructInitializer representing a MDNSService struct """ + # Wrap port in a stateless lambda for TemplatableFn storage + from esphome.cpp_generator import LambdaExpression + + if not isinstance(port, LambdaExpression): + port = LambdaExpression( + f"return {cg.safe_exp(port)};", [], capture="", return_type=cg.uint16 + ) return cg.StructInitializer( MDNSService, ("service_type", cg.RawExpression(f"MDNS_STR({cg.safe_exp(service)})")), From 779faec9a54e4de9e5d8813b54569d1812da00cc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 7 Apr 2026 13:57:10 -1000 Subject: [PATCH 15/20] [mdns] Move LambdaExpression import to top level --- esphome/components/mdns/__init__.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/esphome/components/mdns/__init__.py b/esphome/components/mdns/__init__.py index 6f08887db8..e308f31f30 100644 --- a/esphome/components/mdns/__init__.py +++ b/esphome/components/mdns/__init__.py @@ -13,6 +13,7 @@ from esphome.const import ( ) from esphome.core import CORE, Lambda, coroutine_with_priority from esphome.coroutine import CoroPriority +from esphome.cpp_generator import LambdaExpression from esphome.types import ConfigType CODEOWNERS = ["@esphome/core"] @@ -132,8 +133,6 @@ def mdns_service( A StructInitializer representing a MDNSService struct """ # Wrap port in a stateless lambda for TemplatableFn storage - from esphome.cpp_generator import LambdaExpression - if not isinstance(port, LambdaExpression): port = LambdaExpression( f"return {cg.safe_exp(port)};", [], capture="", return_type=cg.uint16 From 8f6acbd42d704afa848fdf30b1705751e3e09f80 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 7 Apr 2026 14:04:00 -1000 Subject: [PATCH 16/20] [core] Add const to string specialization optional_value/value_or --- esphome/core/automation.h | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/core/automation.h b/esphome/core/automation.h index af730c3545..3b04373fc6 100644 --- a/esphome/core/automation.h +++ b/esphome/core/automation.h @@ -289,13 +289,13 @@ template class TemplatableValue { } } - optional optional_value(X... x) { + optional optional_value(X... x) const { if (!this->has_value()) return {}; return this->value(x...); } - std::string value_or(X... x, std::string default_value) { + std::string value_or(X... x, std::string default_value) const { if (!this->has_value()) return default_value; return this->value(x...); From 0dddeea4d23a8e0d3ae7cccb6366ba4a102e5cad Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 7 Apr 2026 14:10:18 -1000 Subject: [PATCH 17/20] [core] Assert stateless capture in test, fix TemplatableFn comment --- esphome/cpp_generator.py | 2 +- tests/unit_tests/test_cpp_generator.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/cpp_generator.py b/esphome/cpp_generator.py index 55f49fcfa7..48b53b197a 100644 --- a/esphome/cpp_generator.py +++ b/esphome/cpp_generator.py @@ -845,7 +845,7 @@ async def templatable( if output_type is std_string: return FlashStringLiteral(value) # For non-string types, wrap constants in stateless lambdas so that - # TemplatableValue stores them as function pointers (4 bytes vs 8). + # TemplatableFn (used by TEMPLATABLE_VALUE macro) stores them as function pointers. if output_type is not None: from esphome.cpp_types import std_string diff --git a/tests/unit_tests/test_cpp_generator.py b/tests/unit_tests/test_cpp_generator.py index aef6d056b2..3c87e311c3 100644 --- a/tests/unit_tests/test_cpp_generator.py +++ b/tests/unit_tests/test_cpp_generator.py @@ -673,6 +673,7 @@ async def test_templatable__string_with_non_string_output_type() -> None: result = await cg.templatable("hello", [], ct.bool_) assert isinstance(result, cg.LambdaExpression) + assert result.capture == "" @pytest.mark.asyncio From 6d0c61dc738c46b8ccaedd47d309bd4da606b2e2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 7 Apr 2026 14:17:41 -1000 Subject: [PATCH 18/20] [core] Use TemplatableStorage alias to select TemplatableFn or TemplatableValue TEMPLATABLE_VALUE macro now uses TemplatableStorage which selects TemplatableFn for non-string types (4 bytes) and TemplatableValue for std::string (full PROGMEM/FlashStringHelper support). Fixes ESP8266 compile failure where __FlashStringHelper* couldn't assign to TemplatableFn. --- esphome/core/automation.h | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/esphome/core/automation.h b/esphome/core/automation.h index 3b04373fc6..d286782293 100644 --- a/esphome/core/automation.h +++ b/esphome/core/automation.h @@ -62,9 +62,18 @@ template class TemplatableFn { T (*f_)(X...){nullptr}; }; +// Forward declaration for TemplatableValue (string specialization needs it) +template class TemplatableValue; + +/// Selects TemplatableFn (4 bytes) for non-string types, TemplatableValue (8 bytes) for std::string. +/// std::string needs TemplatableValue for const char*, __FlashStringHelper*, and PROGMEM support. +template +using TemplatableStorage = + std::conditional_t, TemplatableValue, TemplatableFn>; + #define TEMPLATABLE_VALUE_(type, name) \ protected: \ - TemplatableFn name##_{}; \ + TemplatableStorage name##_{}; \ \ public: \ template void set_##name(V name) { this->name##_ = name; } From 89bd529cf918e23ea87fc9d3f24bd105b60cb98d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 7 Apr 2026 14:18:32 -1000 Subject: [PATCH 19/20] [mdns] Add comment explaining why manual lambda wrapping is needed --- esphome/components/mdns/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/mdns/__init__.py b/esphome/components/mdns/__init__.py index e308f31f30..79d355e8ae 100644 --- a/esphome/components/mdns/__init__.py +++ b/esphome/components/mdns/__init__.py @@ -132,7 +132,8 @@ def mdns_service( Returns: A StructInitializer representing a MDNSService struct """ - # Wrap port in a stateless lambda for TemplatableFn storage + # Wrap port in a stateless lambda for TemplatableFn storage. + # Can't use cg.templatable() here because this is a sync function. if not isinstance(port, LambdaExpression): port = LambdaExpression( f"return {cg.safe_exp(port)};", [], capture="", return_type=cg.uint16 From a3dec4053d04071f2e3b4b9e6c050121207fc91e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 7 Apr 2026 14:23:10 -1000 Subject: [PATCH 20/20] [http_request] Wrap method constant via cg.templatable for TemplatableFn --- esphome/components/http_request/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/http_request/__init__.py b/esphome/components/http_request/__init__.py index 416432cfc4..ce1a3fcecc 100644 --- a/esphome/components/http_request/__init__.py +++ b/esphome/components/http_request/__init__.py @@ -302,7 +302,8 @@ async def http_request_action_to_code(config, action_id, template_arg, args): template_ = await cg.templatable(config[CONF_URL], args, cg.std_string) cg.add(var.set_url(template_)) - cg.add(var.set_method(config[CONF_METHOD])) + template_ = await cg.templatable(config[CONF_METHOD], args, cg.const_char_ptr) + cg.add(var.set_method(template_)) capture_response = config[CONF_CAPTURE_RESPONSE] if capture_response: