From e7ab67c0312837fc4476764e9a22855d67a6ec5c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 7 Apr 2026 19:43:04 -1000 Subject: [PATCH 1/8] [multiple] Fix raw value codegen for TEMPLATABLE_VALUE setters - max7219digit: wrap bool state via cg.templatable - number: wrap operation enum and cycle bool in deprecated CONF_MODE path - select: wrap operation enum and cycle bool in deprecated CONF_MODE path - datetime: wrap ESPTime struct initializers via cg.templatable - display: wrap DisplayPage* pointer via cg.templatable - speaker/media_player: wrap AudioFile* pointer via cg.templatable --- esphome/components/datetime/__init__.py | 9 ++++++--- esphome/components/display/__init__.py | 3 ++- esphome/components/max7219digit/display.py | 9 ++++++--- esphome/components/number/__init__.py | 8 ++++++-- esphome/components/select/__init__.py | 8 ++++++-- esphome/components/speaker/media_player/__init__.py | 3 ++- 6 files changed, 28 insertions(+), 12 deletions(-) diff --git a/esphome/components/datetime/__init__.py b/esphome/components/datetime/__init__.py index 90835624bf1..895ac4e243e 100644 --- a/esphome/components/datetime/__init__.py +++ b/esphome/components/datetime/__init__.py @@ -204,7 +204,8 @@ async def datetime_date_set_to_code(config, action_id, template_arg, args): ("month", date_config[CONF_MONTH]), ("year", date_config[CONF_YEAR]), ) - cg.add(action_var.set_date(date_struct)) + template_ = await cg.templatable(date_struct, args, cg.ESPTime) + cg.add(action_var.set_date(template_)) return action_var @@ -236,7 +237,8 @@ async def datetime_time_set_to_code(config, action_id, template_arg, args): ("minute", time_config[CONF_MINUTE]), ("hour", time_config[CONF_HOUR]), ) - cg.add(action_var.set_time(time_struct)) + template_ = await cg.templatable(time_struct, args, cg.ESPTime) + cg.add(action_var.set_time(template_)) return action_var @@ -271,5 +273,6 @@ async def datetime_datetime_set_to_code(config, action_id, template_arg, args): ("month", datetime_config[CONF_MONTH]), ("year", datetime_config[CONF_YEAR]), ) - cg.add(action_var.set_datetime(datetime_struct)) + template_ = await cg.templatable(datetime_struct, args, cg.ESPTime) + cg.add(action_var.set_datetime(template_)) return action_var diff --git a/esphome/components/display/__init__.py b/esphome/components/display/__init__.py index 67d76a59d9d..744b5d16c49 100644 --- a/esphome/components/display/__init__.py +++ b/esphome/components/display/__init__.py @@ -207,7 +207,8 @@ async def display_page_show_to_code(config, action_id, template_arg, args): cg.add(var.set_page(template_)) else: paren = await cg.get_variable(config[CONF_ID]) - cg.add(var.set_page(paren)) + template_ = await cg.templatable(paren, args, DisplayPagePtr) + cg.add(var.set_page(template_)) return var diff --git a/esphome/components/max7219digit/display.py b/esphome/components/max7219digit/display.py index eb751b995d1..df2423b0d0e 100644 --- a/esphome/components/max7219digit/display.py +++ b/esphome/components/max7219digit/display.py @@ -147,7 +147,8 @@ MAX7219_ON_ACTION_SCHEMA = automation.maybe_simple_id( async def max7219digit_invert_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) - cg.add(var.set_state(config[CONF_STATE])) + template_ = await cg.templatable(config[CONF_STATE], args, cg.bool_) + cg.add(var.set_state(template_)) return var @@ -166,7 +167,8 @@ async def max7219digit_invert_to_code(config, action_id, template_arg, args): async def max7219digit_visible_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) - cg.add(var.set_state(config[CONF_STATE])) + template_ = await cg.templatable(config[CONF_STATE], args, cg.bool_) + cg.add(var.set_state(template_)) return var @@ -185,7 +187,8 @@ async def max7219digit_visible_to_code(config, action_id, template_arg, args): async def max7219digit_reverse_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) - cg.add(var.set_state(config[CONF_STATE])) + template_ = await cg.templatable(config[CONF_STATE], args, cg.bool_) + cg.add(var.set_state(template_)) return var diff --git a/esphome/components/number/__init__.py b/esphome/components/number/__init__.py index 9fbaff68601..c8441002586 100644 --- a/esphome/components/number/__init__.py +++ b/esphome/components/number/__init__.py @@ -448,7 +448,11 @@ async def number_to_to_code(config, action_id, template_arg, args): template_ = await cg.templatable(cycle, args, bool) cg.add(var.set_cycle(template_)) if (mode := config.get(CONF_MODE)) is not None: - cg.add(var.set_operation(NUMBER_OPERATION_OPTIONS[mode])) + template_ = await cg.templatable( + NUMBER_OPERATION_OPTIONS[mode], args, NumberOperation + ) + cg.add(var.set_operation(template_)) if (cycle := config.get(CONF_CYCLE)) is not None: - cg.add(var.set_cycle(cycle)) + template_ = await cg.templatable(cycle, args, cg.bool_) + cg.add(var.set_cycle(template_)) return var diff --git a/esphome/components/select/__init__.py b/esphome/components/select/__init__.py index b2c17f59ac1..8c7c8f00fa1 100644 --- a/esphome/components/select/__init__.py +++ b/esphome/components/select/__init__.py @@ -282,7 +282,11 @@ async def select_operation_to_code(config, action_id, template_arg, args): template_ = await cg.templatable(cycle, args, bool) cg.add(var.set_cycle(template_)) if (mode := config.get(CONF_MODE)) is not None: - cg.add(var.set_operation(SELECT_OPERATION_OPTIONS[mode])) + template_ = await cg.templatable( + SELECT_OPERATION_OPTIONS[mode], args, SelectOperation + ) + cg.add(var.set_operation(template_)) if (cycle := config.get(CONF_CYCLE)) is not None: - cg.add(var.set_cycle(cycle)) + template_ = await cg.templatable(cycle, args, cg.bool_) + cg.add(var.set_cycle(template_)) return var diff --git a/esphome/components/speaker/media_player/__init__.py b/esphome/components/speaker/media_player/__init__.py index b16f882cbad..320e96c8979 100644 --- a/esphome/components/speaker/media_player/__init__.py +++ b/esphome/components/speaker/media_player/__init__.py @@ -516,7 +516,8 @@ async def play_on_device_media_media_action(config, action_id, template_arg, arg announcement = await cg.templatable(config[CONF_ANNOUNCEMENT], args, cg.bool_) enqueue = await cg.templatable(config[CONF_ENQUEUE], args, cg.bool_) - cg.add(var.set_audio_file(media_file)) + template_ = await cg.templatable(media_file, args, audio.AudioFile.operator("ptr")) + cg.add(var.set_audio_file(template_)) cg.add(var.set_announcement(announcement)) cg.add(var.set_enqueue(enqueue)) return var From e22ded38036074dac72a2ce3a156bfd573179765 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 7 Apr 2026 19:47:43 -1000 Subject: [PATCH 2/8] more fixes --- .../esp32_ble_server/ble_server_automations.h | 8 ++++++-- esphome/core/automation.h | 10 +++++----- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/esphome/components/esp32_ble_server/ble_server_automations.h b/esphome/components/esp32_ble_server/ble_server_automations.h index 0bbfdffd5bd..0c39a40dbb4 100644 --- a/esphome/components/esp32_ble_server/ble_server_automations.h +++ b/esphome/components/esp32_ble_server/ble_server_automations.h @@ -69,7 +69,8 @@ class BLECharacteristicSetValueActionManager { template class BLECharacteristicSetValueAction : public Action { public: BLECharacteristicSetValueAction(BLECharacteristic *characteristic) : parent_(characteristic) {} - TEMPLATABLE_VALUE(std::vector, buffer) + // TemplatableValue (not TemplatableFn) — also set from C++ with raw values (initializer_list, ByteBuffer) + template void set_buffer(V buffer) { this->buffer_ = buffer; } void set_buffer(std::initializer_list buffer) { this->buffer_ = std::vector(buffer); } void set_buffer(ByteBuffer buffer) { this->set_buffer(buffer.get_data()); } void play(const Ts &...x) override { @@ -90,6 +91,7 @@ template class BLECharacteristicSetValueAction : public Action, Ts...> buffer_{}; }; #endif // USE_ESP32_BLE_SERVER_SET_VALUE_ACTION @@ -115,13 +117,15 @@ template class BLECharacteristicNotifyAction : public Action class BLEDescriptorSetValueAction : public Action { public: BLEDescriptorSetValueAction(BLEDescriptor *descriptor) : parent_(descriptor) {} - TEMPLATABLE_VALUE(std::vector, buffer) + // TemplatableValue (not TemplatableFn) — also set from C++ with raw values (initializer_list, ByteBuffer) + template void set_buffer(V buffer) { this->buffer_ = buffer; } void set_buffer(std::initializer_list buffer) { this->buffer_ = std::vector(buffer); } void set_buffer(ByteBuffer buffer) { this->set_buffer(buffer.get_data()); } void play(const Ts &...x) override { this->parent_->set_value(this->buffer_.value(x...)); } protected: BLEDescriptor *parent_; + TemplatableValue, Ts...> buffer_{}; }; #endif // USE_ESP32_BLE_SERVER_DESCRIPTOR_SET_VALUE_ACTION diff --git a/esphome/core/automation.h b/esphome/core/automation.h index 7d5981c3b85..9f62f8fc4ee 100644 --- a/esphome/core/automation.h +++ b/esphome/core/automation.h @@ -80,11 +80,11 @@ template class TemplatableFn { // 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>; +/// TemplatableStorage uses TemplatableValue (8 bytes) for the TEMPLATABLE_VALUE macro. +/// Many components pass raw constants to macro-generated setters from codegen, so the +/// macro must accept both raw values and function pointers. Components that want the +/// 4-byte savings can use TemplatableFn directly instead of the macro. +template using TemplatableStorage = TemplatableValue; #define TEMPLATABLE_VALUE_(type, name) \ protected: \ From ee83f525c41b152f3fa293abe8b8732e5e5f1d82 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 7 Apr 2026 19:51:52 -1000 Subject: [PATCH 3/8] [core] Use is_trivially_copyable_v for TemplatableStorage selection TemplatableStorage now selects TemplatableFn (4 bytes) for trivially copyable types and TemplatableValue (8 bytes) for non-trivial types. This automatically handles std::string (PROGMEM support) and std::vector (raw value assignment from C++) without special-casing. Reverts esp32_ble_server back to TEMPLATABLE_VALUE macro since std::vector now correctly gets TemplatableValue. --- .../esp32_ble_server/ble_server_automations.h | 8 ++------ esphome/core/automation.h | 11 ++++++----- 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/esphome/components/esp32_ble_server/ble_server_automations.h b/esphome/components/esp32_ble_server/ble_server_automations.h index 0c39a40dbb4..0bbfdffd5bd 100644 --- a/esphome/components/esp32_ble_server/ble_server_automations.h +++ b/esphome/components/esp32_ble_server/ble_server_automations.h @@ -69,8 +69,7 @@ class BLECharacteristicSetValueActionManager { template class BLECharacteristicSetValueAction : public Action { public: BLECharacteristicSetValueAction(BLECharacteristic *characteristic) : parent_(characteristic) {} - // TemplatableValue (not TemplatableFn) — also set from C++ with raw values (initializer_list, ByteBuffer) - template void set_buffer(V buffer) { this->buffer_ = buffer; } + TEMPLATABLE_VALUE(std::vector, buffer) void set_buffer(std::initializer_list buffer) { this->buffer_ = std::vector(buffer); } void set_buffer(ByteBuffer buffer) { this->set_buffer(buffer.get_data()); } void play(const Ts &...x) override { @@ -91,7 +90,6 @@ template class BLECharacteristicSetValueAction : public Action, Ts...> buffer_{}; }; #endif // USE_ESP32_BLE_SERVER_SET_VALUE_ACTION @@ -117,15 +115,13 @@ template class BLECharacteristicNotifyAction : public Action class BLEDescriptorSetValueAction : public Action { public: BLEDescriptorSetValueAction(BLEDescriptor *descriptor) : parent_(descriptor) {} - // TemplatableValue (not TemplatableFn) — also set from C++ with raw values (initializer_list, ByteBuffer) - template void set_buffer(V buffer) { this->buffer_ = buffer; } + TEMPLATABLE_VALUE(std::vector, buffer) void set_buffer(std::initializer_list buffer) { this->buffer_ = std::vector(buffer); } void set_buffer(ByteBuffer buffer) { this->set_buffer(buffer.get_data()); } void play(const Ts &...x) override { this->parent_->set_value(this->buffer_.value(x...)); } protected: BLEDescriptor *parent_; - TemplatableValue, Ts...> buffer_{}; }; #endif // USE_ESP32_BLE_SERVER_DESCRIPTOR_SET_VALUE_ACTION diff --git a/esphome/core/automation.h b/esphome/core/automation.h index 9f62f8fc4ee..5a29d61857c 100644 --- a/esphome/core/automation.h +++ b/esphome/core/automation.h @@ -80,11 +80,12 @@ template class TemplatableFn { // Forward declaration for TemplatableValue (string specialization needs it) template class TemplatableValue; -/// TemplatableStorage uses TemplatableValue (8 bytes) for the TEMPLATABLE_VALUE macro. -/// Many components pass raw constants to macro-generated setters from codegen, so the -/// macro must accept both raw values and function pointers. Components that want the -/// 4-byte savings can use TemplatableFn directly instead of the macro. -template using TemplatableStorage = TemplatableValue; +/// Selects TemplatableFn (4 bytes) for trivially copyable types, TemplatableValue (8 bytes) otherwise. +/// Non-trivial types (std::string, std::vector, etc.) need TemplatableValue for raw value +/// storage, PROGMEM/FlashStringHelper support (strings), and proper copy/move/destruction. +template +using TemplatableStorage = + std::conditional_t, TemplatableFn, TemplatableValue>; #define TEMPLATABLE_VALUE_(type, name) \ protected: \ From 49b2882f2b9aad9039447d7e586c8f35b46c9756 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 7 Apr 2026 19:56:41 -1000 Subject: [PATCH 4/8] [core] Fix TemplatableValue union for non-trivially-constructible types Add explicit Storage union with trivial ctor/dtor so that TemplatableValue works with non-trivially-constructible types like std::vector. The union's value_ lifetime is managed externally via placement new and destroy_(). Reverts esp32_ble_server back to TEMPLATABLE_VALUE macro. --- esphome/core/automation.h | 39 +++++++++++++++++++++++---------------- 1 file changed, 23 insertions(+), 16 deletions(-) diff --git a/esphome/core/automation.h b/esphome/core/automation.h index 5a29d61857c..a574872bb23 100644 --- a/esphome/core/automation.h +++ b/esphome/core/automation.h @@ -105,11 +105,13 @@ template class TemplatableValue { // Accept raw constants template TemplatableValue(V value) requires(!std::invocable) : tag_(VALUE) { - new (&this->value_) T(static_cast(std::move(value))); + new (&this->storage_.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; } + template TemplatableValue(F f) requires std::convertible_to : tag_(FN) { + this->storage_.f_ = f; + } // Convertible return type (e.g., int -> uint8_t) — casting trampoline template @@ -117,7 +119,7 @@ template class TemplatableValue { "codegen")]] TemplatableValue(F) requires(!std::convertible_to) && std::invocable &&std::convertible_to, T> &&std::is_empty_v &&std::default_initializable : tag_(FN) { - this->f_ = [](X... x) -> T { return static_cast(F{}(x...)); }; + this->storage_.f_ = [](X... x) -> T { return static_cast(F{}(x...)); }; } // Reject any callable that didn't match the above @@ -129,18 +131,18 @@ template class TemplatableValue { TemplatableValue(const TemplatableValue &other) : tag_(other.tag_) { if (this->tag_ == VALUE) { - new (&this->value_) T(other.value_); + new (&this->storage_.value_) T(other.storage_.value_); } else if (this->tag_ == FN) { - this->f_ = other.f_; + this->storage_.f_ = other.storage_.f_; } } TemplatableValue(TemplatableValue &&other) noexcept : tag_(other.tag_) { if (this->tag_ == VALUE) { - new (&this->value_) T(std::move(other.value_)); + new (&this->storage_.value_) T(std::move(other.storage_.value_)); other.destroy_(); } else if (this->tag_ == FN) { - this->f_ = other.f_; + this->storage_.f_ = other.storage_.f_; } other.tag_ = NONE; } @@ -150,9 +152,9 @@ template class TemplatableValue { this->destroy_(); this->tag_ = other.tag_; if (this->tag_ == VALUE) { - new (&this->value_) T(other.value_); + new (&this->storage_.value_) T(other.storage_.value_); } else if (this->tag_ == FN) { - this->f_ = other.f_; + this->storage_.f_ = other.storage_.f_; } } return *this; @@ -163,10 +165,10 @@ template class TemplatableValue { this->destroy_(); this->tag_ = other.tag_; if (this->tag_ == VALUE) { - new (&this->value_) T(std::move(other.value_)); + new (&this->storage_.value_) T(std::move(other.storage_.value_)); other.destroy_(); } else if (this->tag_ == FN) { - this->f_ = other.f_; + this->storage_.f_ = other.storage_.f_; } other.tag_ = NONE; } @@ -179,9 +181,9 @@ template class TemplatableValue { T value(X... x) const { if (this->tag_ == FN) - return this->f_(x...); + return this->storage_.f_(x...); if (this->tag_ == VALUE) - return this->value_; + return this->storage_.value_; return T{}; } @@ -201,15 +203,20 @@ template class TemplatableValue { void destroy_() { if constexpr (!std::is_trivially_destructible_v) { if (this->tag_ == VALUE) - this->value_.~T(); + this->storage_.value_.~T(); } } enum Tag : uint8_t { NONE, VALUE, FN } tag_{NONE}; - union { + // Union with explicit ctor/dtor to support non-trivially-constructible/destructible T + // (e.g., std::vector). Lifetime of value_ is managed externally via + // placement new and destroy_(). + union Storage { + constexpr Storage() : f_(nullptr) {} + constexpr ~Storage() {} T value_; T (*f_)(X...); - }; + } storage_; }; /// Specialization for std::string: supports VALUE, STATIC_STRING, FLASH_STRING, From 8c0e6198036606119ee4836e5f5e97ff9969041e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 7 Apr 2026 21:20:26 -1000 Subject: [PATCH 5/8] fix missing templatable in lightwaverf and match name to code --- esphome/components/lightwaverf/__init__.py | 16 +++++++--------- esphome/components/lightwaverf/lightwaverf.h | 6 +----- 2 files changed, 8 insertions(+), 14 deletions(-) diff --git a/esphome/components/lightwaverf/__init__.py b/esphome/components/lightwaverf/__init__.py index 46c400cb0e4..76eabc2b712 100644 --- a/esphome/components/lightwaverf/__init__.py +++ b/esphome/components/lightwaverf/__init__.py @@ -61,15 +61,13 @@ async def send_raw_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) - repeats = await cg.templatable(config[CONF_REPEAT], args, int) - inverted = await cg.templatable(config[CONF_INVERTED], args, bool) - pulse_length = await cg.templatable(config[CONF_PULSE_LENGTH], args, int) - code = config[CONF_CODE] - - cg.add(var.set_repeats(repeats)) - cg.add(var.set_inverted(inverted)) - cg.add(var.set_pulse_length(pulse_length)) - cg.add(var.set_data(code)) + template_ = await cg.templatable(config[CONF_REPEAT], args, cg.int_) + cg.add(var.set_repeat(template_)) + template_ = await cg.templatable(config[CONF_INVERTED], args, cg.int_) + cg.add(var.set_inverted(template_)) + template_ = await cg.templatable(config[CONF_PULSE_LENGTH], args, cg.int_) + cg.add(var.set_pulse_length(template_)) + cg.add(var.set_code(config[CONF_CODE])) return var diff --git a/esphome/components/lightwaverf/lightwaverf.h b/esphome/components/lightwaverf/lightwaverf.h index ee4e91e9d1d..6210e6b5d47 100644 --- a/esphome/components/lightwaverf/lightwaverf.h +++ b/esphome/components/lightwaverf/lightwaverf.h @@ -45,11 +45,7 @@ template class SendRawAction : public Action { TEMPLATABLE_VALUE(int, inverted); TEMPLATABLE_VALUE(int, pulse_length); TEMPLATABLE_VALUE(std::vector, code); - - void set_repeats(const int &data) { repeat_ = data; } - void set_inverted(const int &data) { inverted_ = data; } - void set_pulse_length(const int &data) { pulse_length_ = data; } - void set_data(const std::vector &data) { code_ = data; } + void set_code(std::initializer_list data) { this->code_ = std::vector(data); } void play(const Ts &...x) { int repeats = this->repeat_.value(x...); From b18c911429fc25a139815e865d9bab8aacaf3dae Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 7 Apr 2026 21:23:27 -1000 Subject: [PATCH 6/8] another one --- esphome/components/cc1101/__init__.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/esphome/components/cc1101/__init__.py b/esphome/components/cc1101/__init__.py index 27092908621..0feb384ac23 100644 --- a/esphome/components/cc1101/__init__.py +++ b/esphome/components/cc1101/__init__.py @@ -423,11 +423,10 @@ def _register_setter_actions(): var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) data = config[CONF_VALUE] - if cg.is_template(data): - templ_ = await cg.templatable(data, args, _type) - cg.add(getattr(var, _setter)(templ_)) - else: - cg.add(getattr(var, _setter)(_map[data] if _map else data)) + if _map and not cg.is_template(data): + data = _map[data] + templ_ = await cg.templatable(data, args, _type) + cg.add(getattr(var, _setter)(templ_)) return var automation.register_action( From fe17c86f58b1bee4aa023d53437836610ece2259 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 7 Apr 2026 21:32:20 -1000 Subject: [PATCH 7/8] now fix is always the same for unwrapped --- esphome/components/globals/__init__.py | 15 +++------------ esphome/cpp_generator.py | 8 +++++--- tests/unit_tests/test_cpp_generator.py | 16 +++++++++------- 3 files changed, 17 insertions(+), 22 deletions(-) diff --git a/esphome/components/globals/__init__.py b/esphome/components/globals/__init__.py index c0694093606..fe83b1ea7c3 100644 --- a/esphome/components/globals/__init__.py +++ b/esphome/components/globals/__init__.py @@ -9,7 +9,6 @@ from esphome.const import ( CONF_VALUE, ) from esphome.core import CoroPriority, coroutine_with_priority -from esphome.cpp_generator import LambdaExpression from esphome.types import ConfigType CODEOWNERS = ["@esphome/core"] @@ -109,16 +108,8 @@ async def globals_set_to_code(config, action_id, template_arg, args): full_id, paren = await cg.get_variable_with_full_id(config[CONF_ID]) template_arg = cg.TemplateArguments(full_id.type, *template_arg) var = cg.new_Pvariable(action_id, template_arg, paren) - value = config[CONF_VALUE] - if cg.is_template(value): - templ = await cg.templatable(value, args, None, to_exp=cg.RawExpression) - else: - # Wrap raw constant in a stateless lambda for TemplatableFn storage. - # Use RawExpression for the value since T is a template parameter - # (the C++ compiler handles the type deduction). - raw_value = cg.RawExpression(value) - templ = LambdaExpression( - f"return {cg.safe_exp(raw_value)};", args, capture="", return_type=None - ) + templ = await cg.templatable( + config[CONF_VALUE], args, None, to_exp=cg.RawExpression + ) cg.add(var.set_value(templ)) return var diff --git a/esphome/cpp_generator.py b/esphome/cpp_generator.py index c41171257b9..814e37e02cb 100644 --- a/esphome/cpp_generator.py +++ b/esphome/cpp_generator.py @@ -848,9 +848,11 @@ async def templatable( # Automatically wrap static strings in ESPHOME_F() for PROGMEM storage on ESP8266. # On other platforms ESPHOME_F() is a no-op returning const char*. return FlashStringLiteral(value) - # For non-string types, wrap constants in stateless lambdas so that - # TemplatableFn (used by TEMPLATABLE_VALUE macro) stores them as function pointers. - if output_type is not None and output_type is not std_string: + # Wrap non-string constants in stateless lambdas so that TemplatableFn + # (used by TEMPLATABLE_VALUE macro) stores them as function pointers. + # When output_type is None, the lambda omits the return type annotation + # and the C++ compiler deduces it (used by globals where T is unknown). + if output_type is not std_string: return LambdaExpression( f"return {safe_exp(value)};", args, diff --git a/tests/unit_tests/test_cpp_generator.py b/tests/unit_tests/test_cpp_generator.py index 81ae586e23b..c75851df0cb 100644 --- a/tests/unit_tests/test_cpp_generator.py +++ b/tests/unit_tests/test_cpp_generator.py @@ -652,11 +652,11 @@ async def test_templatable__empty_string_with_std_string() -> None: @pytest.mark.asyncio async def test_templatable__string_with_none_output_type() -> None: - """Static string with output_type=None returns raw string (no wrapping).""" + """Static string with output_type=None returns stateless lambda (no return type).""" result = await cg.templatable("hello", [], None) - assert isinstance(result, str) - assert result == "hello" + assert isinstance(result, cg.LambdaExpression) + assert result.capture == "" @pytest.mark.asyncio @@ -678,10 +678,11 @@ async def test_templatable__string_with_non_string_output_type() -> None: @pytest.mark.asyncio async def test_templatable__with_to_exp_callable() -> None: - """When to_exp is provided, it is applied to non-template values.""" + """When to_exp is provided with output_type=None, result is lambda-wrapped.""" result = await cg.templatable(42, [], None, to_exp=lambda x: x * 2) - assert result == 84 + assert isinstance(result, cg.LambdaExpression) + assert result.capture == "" @pytest.mark.asyncio @@ -695,11 +696,12 @@ async def test_templatable__with_to_exp_callable_and_output_type() -> None: @pytest.mark.asyncio async def test_templatable__with_to_exp_dict() -> None: - """When to_exp is a dict, value is looked up.""" + """When to_exp is a dict, value is looked up and lambda-wrapped.""" mapping: dict[str, int] = {"on": 1, "off": 0} result = await cg.templatable("on", [], None, to_exp=mapping) - assert result == 1 + assert isinstance(result, cg.LambdaExpression) + assert result.capture == "" @pytest.mark.asyncio From 76ef8be109e404e8e95b3f5366d71fc1c100d9a4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 7 Apr 2026 21:50:25 -1000 Subject: [PATCH 8/8] make the bot happy --- esphome/core/automation.h | 2 ++ 1 file changed, 2 insertions(+) diff --git a/esphome/core/automation.h b/esphome/core/automation.h index a574872bb23..eb270bfee26 100644 --- a/esphome/core/automation.h +++ b/esphome/core/automation.h @@ -40,6 +40,7 @@ template struct gens<0, S...> { using type = seq; }; template class TemplatableFn { public: TemplatableFn() = default; + TemplatableFn(std::nullptr_t) = delete; // Exact return type match — direct function pointer storage template TemplatableFn(F f) requires std::convertible_to : f_(f) {} @@ -102,6 +103,7 @@ using TemplatableStorage = template class TemplatableValue { public: TemplatableValue() = default; + TemplatableValue(std::nullptr_t) = delete; // Accept raw constants template TemplatableValue(V value) requires(!std::invocable) : tag_(VALUE) {