[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.
This commit is contained in:
J. Nick Koston
2026-04-07 13:16:14 -10:00
parent aad898503d
commit 9bf0133525
8 changed files with 161 additions and 192 deletions
+1 -1
View File
@@ -275,7 +275,7 @@ template<typename... Ts> class APIRespondAction : public Action<Ts...> {
protected:
APIServer *parent_;
TemplatableValue<bool, Ts...> success_{true};
TemplatableValue<bool, Ts...> success_{[](Ts...) -> bool { return true; }};
TemplatableValue<std::string, Ts...> error_message_{""};
#ifdef USE_API_USER_DEFINED_ACTION_RESPONSES_JSON
std::function<void(Ts..., JsonObject)> json_builder_;
+42 -33
View File
@@ -24,51 +24,60 @@ template<typename... Ts> class ToggleAction : public Action<Ts...> {
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<typename... Ts> class LightControlAction : public Action<Ts...> {
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<typename... Ts> class DimRelativeAction : public Action<Ts...> {
+6 -32
View File
@@ -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 <value>; }`` 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
+2 -2
View File
@@ -63,8 +63,8 @@ class ValueRangeTrigger : public Trigger<float>, public Component {
Number *parent_;
ESPPreferenceObject rtc_;
bool previous_in_range_{false};
TemplatableValue<float, float> min_{NAN};
TemplatableValue<float, float> max_{NAN};
TemplatableValue<float, float> min_{[](float) -> float { return NAN; }}; // NAN = no bound
TemplatableValue<float, float> max_{[](float) -> float { return NAN; }}; // NAN = no bound
};
template<typename... Ts> class NumberInRangeCondition : public Condition<Ts...> {
+2 -2
View File
@@ -79,8 +79,8 @@ class ValueRangeTrigger : public Trigger<float>, public Component {
Sensor *parent_;
ESPPreferenceObject rtc_;
bool previous_in_range_{false};
TemplatableValue<float, float> min_{NAN};
TemplatableValue<float, float> max_{NAN};
TemplatableValue<float, float> min_{[](float) -> float { return NAN; }};
TemplatableValue<float, float> max_{[](float) -> float { return NAN; }};
};
template<typename... Ts> class SensorInRangeCondition : public Condition<Ts...> {
+84 -107
View File
@@ -43,61 +43,78 @@ template<int... S> struct gens<0, S...> { using type = seq<S...>; };
#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<typename T, typename... X> 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<T, std::string>;
public:
TemplatableValue() = default;
// Accept stateless lambdas (convertible to function pointer)
template<typename F> TemplatableValue(F f) requires std::convertible_to<F, T (*)(X...)> : f_(f) {}
// Reject stateful lambdas at compile time
template<typename F>
TemplatableValue(F) requires std::invocable<F, X...> &&(!std::convertible_to<F, T (*)(X...)>) = delete;
bool has_value() const { return this->f_ != nullptr; }
T value(X... x) const { return this->f_ ? this->f_(x...) : T{}; }
optional<T> 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<typename... X> class TemplatableValue<std::string, X...> {
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<T, std::string> : 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<T, std::string> : 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<const char *>(str);
}
#endif
template<typename F> TemplatableValue(F value) requires(!std::invocable<F, X...>) : 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<typename F>
TemplatableValue(F f) requires std::invocable<F, X...> && std::convertible_to<F, T (*)(X...)>
TemplatableValue(F f) requires std::invocable<F, X...> && std::convertible_to<F, std::string (*)(X...)>
: 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<typename F>
TemplatableValue(F f) requires std::invocable<F, X...> &&(!std::convertible_to<F, T (*)(X...)>) : type_(LAMBDA) {
this->f_ = new std::function<T(X...)>(std::move(f));
TemplatableValue(F f) requires std::invocable<F, X...> &&(!std::convertible_to<F, std::string (*)(X...)>)
: type_(LAMBDA) {
this->f_ = new std::function<std::string(X...)>(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<T(X...)>(*other.f_);
this->f_ = new std::function<std::string(X...)>(*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<typename T, typename... X> 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<typename T, typename... X> class TemplatableValue {
other.type_ = NONE;
}
// Assignment operators
TemplatableValue &operator=(const TemplatableValue &other) {
if (this != &other) {
this->~TemplatableValue();
@@ -144,82 +155,58 @@ template<typename T, typename... X> 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<T, std::string>) {
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<T, std::string>) {
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<T> optional_value(X... x) {
if (!this->has_value()) {
optional<std::string> 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<T, std::string> {
/// 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<typename T, typename... X> 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<const uint8_t *>(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<T, std::string> {
/// 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<typename T, typename... X> 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<typename T, typename... X> 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<typename T, typename... X> 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<USE_HEAP_STORAGE, T *, 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<T(X...)> *f_;
T (*stateless_f_)(X...);
const char *static_str_; // For STATIC_STRING and FLASH_STRING types
std::string *value_;
std::function<std::string(X...)> *f_;
std::string (*stateless_f_)(X...);
const char *static_str_;
};
};
+22 -12
View File
@@ -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):
+2 -3
View File
@@ -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