1
0
mirror of https://github.com/esphome/esphome.git synced 2026-02-10 01:32:06 +00:00

Compare commits

...

9 Commits

Author SHA1 Message Date
J. Nick Koston
2590636e48 Merge branch 'dev' into api-flash-string-progmem 2026-02-09 12:42:08 -06:00
J. Nick Koston
cf7da8e86d fix 2026-02-09 12:34:16 -06:00
J. Nick Koston
dbef2e24b3 Fix variables returning non-string types, use identity check for std_string
- Revert variables templatable output_type back to None since variable
  lambdas can return any type (float, int, etc.), not just strings.
- Use lazy import identity check (output_type is std_string) instead of
  brittle string comparison in templatable().
- Clarify ESP8266 populate_service_map comment explaining why STATIC_STRING
  fast path is not needed (all codegen strings are FLASH_STRING on ESP8266).
2026-02-09 11:58:55 -06:00
J. Nick Koston
9440138ac7 fix 2026-02-09 11:58:54 -06:00
J. Nick Koston
5cf27a8927 Merge branch 'dev' into api-flash-string-progmem 2026-02-09 11:26:46 -06:00
J. Nick Koston
5d5344cf91 Add tests for cg.templatable() auto FlashStringLiteral wrapping
Cover the new automatic ESPHOME_F() wrapping behavior: static strings
with std::string output_type, non-string values, None output_type,
to_exp callable/dict, and lambda passthrough.
2026-02-09 10:56:56 -06:00
J. Nick Koston
b0cf94c409 Auto-wrap static strings in ESPHOME_F() via templatable()
Move FlashStringLiteral wrapping from per-component manual code into
cg.templatable() itself. When output_type is std::string and the value
is a static string (not a lambda), it is automatically wrapped in
ESPHOME_F() for PROGMEM storage on ESP8266. On other platforms
ESPHOME_F() is a no-op returning const char*.

This makes all ~50 existing cg.templatable(..., cg.std_string) call
sites across every component benefit automatically, with no
per-component changes needed.

Simplify api/__init__.py by switching from output_type=None to
cg.std_string and removing the manual isinstance/FlashStringLiteral
checks that are now redundant.
2026-02-09 10:38:41 -06:00
J. Nick Koston
c990da265a Add unit tests for FlashStringLiteral
Cover the three lines reported uncovered by codecov in
cpp_generator.py (FlashStringLiteral.__init__ and __str__).
2026-02-09 07:45:03 -06:00
J. Nick Koston
91487e7f14 [api] Store HomeAssistant action strings in PROGMEM on ESP8266
On ESP8266, .rodata is copied to RAM at boot. Every string literal in
HomeAssistantServiceCallAction (service names, data keys, data values)
permanently consumes RAM even though the action may rarely fire.

Add FLASH_STRING type to TemplatableValue that stores PROGMEM pointers
on ESP8266 via the existing __FlashStringHelper* type. At play() time,
strings are copied from flash to temporary std::string storage — safe
because service calls are not in the hot path.

Add FlashStringLiteral codegen helper (cg.FlashStringLiteral) that wraps
strings in ESPHOME_F() — expands to F() on ESP8266 (PROGMEM), plain
string on other platforms (no-op). This helper can be adopted by other
components incrementally.

On non-ESP8266 platforms, FLASH_STRING is never set and all existing
code paths are unchanged.
2026-02-09 07:39:06 -06:00
6 changed files with 223 additions and 18 deletions

View File

@@ -11,6 +11,7 @@
from esphome.cpp_generator import ( # noqa: F401
ArrayInitializer,
Expression,
FlashStringLiteral,
LineComment,
LogStringLiteral,
MockObj,

View File

@@ -524,24 +524,31 @@ async def homeassistant_service_to_code(
cg.add_define("USE_API_HOMEASSISTANT_SERVICES")
serv = await cg.get_variable(config[CONF_ID])
var = cg.new_Pvariable(action_id, template_arg, serv, False)
templ = await cg.templatable(config[CONF_ACTION], args, None)
templ = await cg.templatable(config[CONF_ACTION], args, cg.std_string)
cg.add(var.set_service(templ))
# Initialize FixedVectors with exact sizes from config
cg.add(var.init_data(len(config[CONF_DATA])))
for key, value in config[CONF_DATA].items():
# output_type=None because lambdas can return non-string types (int,
# float, char*) that TemplatableStringValue converts via to_string.
# Static strings are manually wrapped for PROGMEM on ESP8266.
templ = await cg.templatable(value, args, None)
cg.add(var.add_data(key, templ))
if isinstance(templ, str):
templ = cg.FlashStringLiteral(templ)
cg.add(var.add_data(cg.FlashStringLiteral(key), templ))
cg.add(var.init_data_template(len(config[CONF_DATA_TEMPLATE])))
for key, value in config[CONF_DATA_TEMPLATE].items():
templ = await cg.templatable(value, args, None)
cg.add(var.add_data_template(key, templ))
if isinstance(templ, str):
templ = cg.FlashStringLiteral(templ)
cg.add(var.add_data_template(cg.FlashStringLiteral(key), templ))
cg.add(var.init_variables(len(config[CONF_VARIABLES])))
for key, value in config[CONF_VARIABLES].items():
templ = await cg.templatable(value, args, None)
cg.add(var.add_variable(key, templ))
cg.add(var.add_variable(cg.FlashStringLiteral(key), templ))
if on_error := config.get(CONF_ON_ERROR):
cg.add_define("USE_API_HOMEASSISTANT_ACTION_RESPONSES")
@@ -609,24 +616,31 @@ async def homeassistant_event_to_code(config, action_id, template_arg, args):
cg.add_define("USE_API_HOMEASSISTANT_SERVICES")
serv = await cg.get_variable(config[CONF_ID])
var = cg.new_Pvariable(action_id, template_arg, serv, True)
templ = await cg.templatable(config[CONF_EVENT], args, None)
templ = await cg.templatable(config[CONF_EVENT], args, cg.std_string)
cg.add(var.set_service(templ))
# Initialize FixedVectors with exact sizes from config
cg.add(var.init_data(len(config[CONF_DATA])))
for key, value in config[CONF_DATA].items():
# output_type=None because lambdas can return non-string types (int,
# float, char*) that TemplatableStringValue converts via to_string.
# Static strings are manually wrapped for PROGMEM on ESP8266.
templ = await cg.templatable(value, args, None)
cg.add(var.add_data(key, templ))
if isinstance(templ, str):
templ = cg.FlashStringLiteral(templ)
cg.add(var.add_data(cg.FlashStringLiteral(key), templ))
cg.add(var.init_data_template(len(config[CONF_DATA_TEMPLATE])))
for key, value in config[CONF_DATA_TEMPLATE].items():
templ = await cg.templatable(value, args, None)
cg.add(var.add_data_template(key, templ))
if isinstance(templ, str):
templ = cg.FlashStringLiteral(templ)
cg.add(var.add_data_template(cg.FlashStringLiteral(key), templ))
cg.add(var.init_variables(len(config[CONF_VARIABLES])))
for key, value in config[CONF_VARIABLES].items():
templ = await cg.templatable(value, args, None)
cg.add(var.add_variable(key, templ))
cg.add(var.add_variable(cg.FlashStringLiteral(key), templ))
return var
@@ -649,11 +663,11 @@ async def homeassistant_tag_scanned_to_code(config, action_id, template_arg, arg
cg.add_define("USE_API_HOMEASSISTANT_SERVICES")
serv = await cg.get_variable(config[CONF_ID])
var = cg.new_Pvariable(action_id, template_arg, serv, True)
cg.add(var.set_service("esphome.tag_scanned"))
cg.add(var.set_service(cg.FlashStringLiteral("esphome.tag_scanned")))
# Initialize FixedVector with exact size (1 data field)
cg.add(var.init_data(1))
templ = await cg.templatable(config[CONF_TAG], args, cg.std_string)
cg.add(var.add_data("tag_id", templ))
cg.add(var.add_data(cg.FlashStringLiteral("tag_id"), templ))
return var

View File

@@ -128,6 +128,20 @@ template<typename... Ts> class HomeAssistantServiceCallAction : public Action<Ts
this->add_kv_(this->variables_, key, std::forward<V>(value));
}
#ifdef USE_ESP8266
// On ESP8266, ESPHOME_F() returns __FlashStringHelper* (PROGMEM pointer).
// Store as const char* — populate_service_map copies from PROGMEM at play() time.
template<typename V> void add_data(const __FlashStringHelper *key, V &&value) {
this->add_kv_(this->data_, reinterpret_cast<const char *>(key), std::forward<V>(value));
}
template<typename V> void add_data_template(const __FlashStringHelper *key, V &&value) {
this->add_kv_(this->data_template_, reinterpret_cast<const char *>(key), std::forward<V>(value));
}
template<typename V> void add_variable(const __FlashStringHelper *key, V &&value) {
this->add_kv_(this->variables_, reinterpret_cast<const char *>(key), std::forward<V>(value));
}
#endif
#ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES
template<typename T> void set_response_template(T response_template) {
this->response_template_ = response_template;
@@ -219,7 +233,32 @@ template<typename... Ts> class HomeAssistantServiceCallAction : public Action<Ts
Ts... x) {
dest.init(source.size());
// Count non-static strings to allocate exact storage needed
#ifdef USE_ESP8266
// On ESP8266, all static strings from codegen are FLASH_STRING (PROGMEM),
// so is_static_string() is always false — the zero-copy STATIC_STRING fast
// path from the non-ESP8266 branch cannot trigger. We copy all keys and
// values unconditionally: keys via _P functions (may be in PROGMEM), values
// via value() which handles FLASH_STRING internally.
value_storage.init(source.size() * 2);
for (auto &it : source) {
auto &kv = dest.emplace_back();
// Key: copy from possible PROGMEM
{
size_t key_len = strlen_P(it.key);
value_storage.push_back(std::string(key_len, '\0'));
memcpy_P(value_storage.back().data(), it.key, key_len);
kv.key = StringRef(value_storage.back());
}
// Value: value() handles FLASH_STRING via _P functions internally
value_storage.push_back(it.value.value(x...));
kv.value = StringRef(value_storage.back());
}
#else
// On non-ESP8266, strings are directly readable from flash-mapped memory.
// Count non-static strings to allocate exact storage needed.
size_t lambda_count = 0;
for (const auto &it : source) {
if (!it.value.is_static_string()) {
@@ -233,14 +272,15 @@ template<typename... Ts> class HomeAssistantServiceCallAction : public Action<Ts
kv.key = StringRef(it.key);
if (it.value.is_static_string()) {
// Static string from YAML - zero allocation
// Static string — pointer directly readable, zero allocation
kv.value = StringRef(it.value.get_static_string());
} else {
// Lambda evaluation - store result, reference it
// Lambda evaluate and store result
value_storage.push_back(it.value.value(x...));
kv.value = StringRef(value_storage.back());
}
}
#endif
}
APIServer *parent_;

View File

@@ -4,6 +4,7 @@
#include "esphome/core/defines.h"
#include "esphome/core/helpers.h"
#include "esphome/core/preferences.h"
#include "esphome/core/progmem.h"
#include "esphome/core/string_ref.h"
#include <concepts>
#include <functional>
@@ -56,6 +57,16 @@ template<typename T, typename... X> class TemplatableValue {
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) {
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));
@@ -89,7 +100,7 @@ template<typename T, typename... X> class TemplatableValue {
this->f_ = new std::function<T(X...)>(*other.f_);
} else if (this->type_ == STATELESS_LAMBDA) {
this->stateless_f_ = other.stateless_f_;
} else if (this->type_ == STATIC_STRING) {
} else if (this->type_ == STATIC_STRING || this->type_ == FLASH_STRING) {
this->static_str_ = other.static_str_;
}
}
@@ -108,7 +119,7 @@ template<typename T, typename... X> class TemplatableValue {
other.f_ = nullptr;
} else if (this->type_ == STATELESS_LAMBDA) {
this->stateless_f_ = other.stateless_f_;
} else if (this->type_ == STATIC_STRING) {
} else if (this->type_ == STATIC_STRING || this->type_ == FLASH_STRING) {
this->static_str_ = other.static_str_;
}
other.type_ = NONE;
@@ -141,7 +152,7 @@ template<typename T, typename... X> class TemplatableValue {
} else if (this->type_ == LAMBDA) {
delete this->f_;
}
// STATELESS_LAMBDA/STATIC_STRING/NONE: no cleanup needed (pointers, not heap-allocated)
// STATELESS_LAMBDA/STATIC_STRING/FLASH_STRING/NONE: no cleanup needed (pointers, not heap-allocated)
}
bool has_value() const { return this->type_ != NONE; }
@@ -165,6 +176,17 @@ template<typename T, typename... X> class TemplatableValue {
return std::string(this->static_str_);
}
__builtin_unreachable();
#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();
#endif
case NONE:
default:
return T{};
@@ -186,9 +208,12 @@ template<typename T, typename... X> class TemplatableValue {
}
/// 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).
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.
const char *get_static_string() const { return this->static_str_; }
/// Check if the string value is empty without allocating (for std::string specialization).
@@ -200,6 +225,12 @@ template<typename T, typename... X> class TemplatableValue {
return true;
case STATIC_STRING:
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()
@@ -209,8 +240,9 @@ template<typename T, typename... X> class TemplatableValue {
/// 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 lambda case (must remain valid while StringRef is used).
/// @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> {
@@ -221,6 +253,19 @@ template<typename T, typename... X> class TemplatableValue {
if (this->static_str_ == nullptr)
return StringRef();
return StringRef(this->static_str_, strlen(this->static_str_));
#ifdef USE_ESP8266
case FLASH_STRING:
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);
lambda_buf[copy_len] = '\0';
return StringRef(lambda_buf, copy_len);
}
#endif
case VALUE:
return StringRef(this->value_->data(), this->value_->size());
default: { // LAMBDA/STATELESS_LAMBDA - must call value() and copy
@@ -239,6 +284,7 @@ template<typename T, typename... X> class TemplatableValue {
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.
@@ -247,7 +293,7 @@ template<typename T, typename... X> class TemplatableValue {
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 type
const char *static_str_; // For STATIC_STRING and FLASH_STRING types
};
};

View File

@@ -247,6 +247,23 @@ class LogStringLiteral(Literal):
return f"LOG_STR({cpp_string_escape(self.string)})"
class FlashStringLiteral(Literal):
"""A string literal wrapped in ESPHOME_F() for PROGMEM storage on ESP8266.
On ESP8266, ESPHOME_F(s) expands to F(s) which stores the string in flash (PROGMEM).
On other platforms, ESPHOME_F(s) expands to plain s (no-op).
"""
__slots__ = ("string",)
def __init__(self, string: str) -> None:
super().__init__()
self.string = string
def __str__(self) -> str:
return f"ESPHOME_F({cpp_string_escape(self.string)})"
class IntLiteral(Literal):
__slots__ = ("i",)
@@ -761,6 +778,15 @@ async def templatable(
if is_template(value):
return await process_lambda(value, args, return_type=output_type)
if to_exp is 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
if output_type is std_string:
return FlashStringLiteral(value)
return value
if isinstance(to_exp, dict):
return to_exp[value]

View File

@@ -248,6 +248,12 @@ class TestLiterals:
(cg.FloatLiteral(4.2), "4.2f"),
(cg.FloatLiteral(1.23456789), "1.23456789f"),
(cg.FloatLiteral(math.nan), "NAN"),
(cg.FlashStringLiteral("hello"), 'ESPHOME_F("hello")'),
(cg.FlashStringLiteral(""), 'ESPHOME_F("")'),
(
cg.FlashStringLiteral('quote"here'),
'ESPHOME_F("quote\\042here")',
),
),
)
def test_str__simple(self, target: cg.Literal, expected: str):
@@ -624,3 +630,75 @@ class TestProcessLambda:
# Test invalid tuple format (single element)
with pytest.raises(AssertionError):
await cg.process_lambda(lambda_obj, [(int,)])
@pytest.mark.asyncio
async def test_templatable__string_with_std_string_returns_flash_literal() -> None:
"""Static string with std::string output_type returns FlashStringLiteral."""
result = await cg.templatable("hello", [], ct.std_string)
assert isinstance(result, cg.FlashStringLiteral)
assert str(result) == 'ESPHOME_F("hello")'
@pytest.mark.asyncio
async def test_templatable__empty_string_with_std_string() -> None:
"""Empty static string with std::string output_type returns FlashStringLiteral."""
result = await cg.templatable("", [], ct.std_string)
assert isinstance(result, cg.FlashStringLiteral)
assert str(result) == 'ESPHOME_F("")'
@pytest.mark.asyncio
async def test_templatable__string_with_none_output_type() -> None:
"""Static string with output_type=None returns raw string (no wrapping)."""
result = await cg.templatable("hello", [], None)
assert isinstance(result, str)
assert result == "hello"
@pytest.mark.asyncio
async def test_templatable__int_with_std_string() -> None:
"""Non-string value with std::string output_type returns raw value."""
result = await cg.templatable(42, [], ct.std_string)
assert result == 42
@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."""
result = await cg.templatable("hello", [], ct.bool_)
assert isinstance(result, str)
assert result == "hello"
@pytest.mark.asyncio
async def test_templatable__with_to_exp_callable() -> None:
"""When to_exp is provided, it is applied to non-template values."""
result = await cg.templatable(42, [], None, to_exp=lambda x: x * 2)
assert result == 84
@pytest.mark.asyncio
async def test_templatable__with_to_exp_dict() -> None:
"""When to_exp is a dict, value is looked up."""
mapping: dict[str, int] = {"on": 1, "off": 0}
result = await cg.templatable("on", [], None, to_exp=mapping)
assert result == 1
@pytest.mark.asyncio
async def test_templatable__lambda_with_std_string() -> None:
"""Lambda value returns LambdaExpression, not FlashStringLiteral."""
from esphome.core import Lambda
lambda_obj = Lambda('return "hello";')
result = await cg.templatable(lambda_obj, [], ct.std_string)
assert isinstance(result, cg.LambdaExpression)