From 807e3f9efc95074f4cc67b55c4dd866b0404b8a0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 3 Mar 2026 08:04:09 -1000 Subject: [PATCH 1/5] move icons to progmem --- esphome/components/api/api_connection.h | 3 +- esphome/components/mqtt/mqtt_component.cpp | 10 ++--- esphome/components/mqtt/mqtt_component.h | 4 +- esphome/components/web_server/web_server.cpp | 3 +- esphome/config_validation.py | 15 ++++--- esphome/core/entity_base.cpp | 27 ++++++++++-- esphome/core/entity_base.h | 33 +++++++++++--- esphome/core/entity_helpers.py | 46 +++++++++++++++++--- 8 files changed, 112 insertions(+), 29 deletions(-) diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 37855b2482a..1282cb0a460 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -348,7 +348,8 @@ class APIConnection final : public APIServerConnectionBase { // Set common EntityBase properties #ifdef USE_ENTITY_ICON - msg.icon = entity->get_icon_ref(); + char icon_buf[MAX_ICON_LENGTH]; + msg.icon = entity->get_icon_to(icon_buf); #endif msg.disabled_by_default = entity->is_disabled_by_default(); msg.entity_category = static_cast(entity->get_entity_category()); diff --git a/esphome/components/mqtt/mqtt_component.cpp b/esphome/components/mqtt/mqtt_component.cpp index f49069960b3..98fa10def95 100644 --- a/esphome/components/mqtt/mqtt_component.cpp +++ b/esphome/components/mqtt/mqtt_component.cpp @@ -209,12 +209,11 @@ bool MQTTComponent::send_discovery_() { if (this->is_disabled_by_default_()) root[MQTT_ENABLED_BY_DEFAULT] = false; - // NOLINTBEGIN(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson - const auto icon_ref = this->get_icon_ref_(); - if (!icon_ref.empty()) { - root[MQTT_ICON] = icon_ref; + char icon_buf[MAX_ICON_LENGTH]; + const char *icon = this->get_icon_to_(icon_buf); + if (icon[0] != '\0') { + root[MQTT_ICON] = icon; } - // NOLINTEND(clang-analyzer-cplusplus.NewDeleteLeaks) const auto entity_category = this->get_entity()->get_entity_category(); if (entity_category != ENTITY_CATEGORY_NONE) { @@ -413,7 +412,6 @@ const StringRef &MQTTComponent::friendly_name_() const { return this->get_entity StringRef MQTTComponent::get_default_object_id_to_(std::span buf) const { return this->get_entity()->get_object_id_to(buf); } -StringRef MQTTComponent::get_icon_ref_() const { return this->get_entity()->get_icon_ref(); } bool MQTTComponent::is_disabled_by_default_() const { return this->get_entity()->is_disabled_by_default(); } bool MQTTComponent::compute_is_internal_() { if (this->custom_state_topic_.has_value()) { diff --git a/esphome/components/mqtt/mqtt_component.h b/esphome/components/mqtt/mqtt_component.h index 0ffe6341d37..2403ef64ea3 100644 --- a/esphome/components/mqtt/mqtt_component.h +++ b/esphome/components/mqtt/mqtt_component.h @@ -298,8 +298,8 @@ class MQTTComponent : public Component { /// Get the friendly name of this MQTT component. const StringRef &friendly_name_() const; - /// Get the icon field of this component as StringRef - StringRef get_icon_ref_() const; + /// Get the icon field of this component into a stack buffer + const char *get_icon_to_(std::span buf) const { return this->get_entity()->get_icon_to(buf); } /// Get whether the underlying Entity is disabled by default bool is_disabled_by_default_() const; diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 6b94a103cc9..bc90c88e57f 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -568,7 +568,8 @@ static void set_json_id(JsonObject &root, EntityBase *obj, const char *prefix, J } #endif #ifdef USE_ENTITY_ICON - root[ESPHOME_F("icon")] = obj->get_icon_ref().c_str(); + char icon_buf[MAX_ICON_LENGTH]; + root[ESPHOME_F("icon")] = obj->get_icon_to(icon_buf); #endif root[ESPHOME_F("entity_category")] = obj->get_entity_category(); bool is_disabled = obj->is_disabled_by_default(); diff --git a/esphome/config_validation.py b/esphome/config_validation.py index 3b0e4da298a..69f8ea9ef81 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -403,11 +403,16 @@ def icon(value): value = string_strict(value) if not value: return value - if re.match("^[\\w\\-]+:[\\w\\-]+$", value): - return value - raise Invalid( - 'Icons must match the format "[icon pack]:[icon]", e.g. "mdi:home-assistant"' - ) + if not re.match("^[\\w\\-]+:[\\w\\-]+$", value): + raise Invalid( + 'Icons must match the format "[icon pack]:[icon]", e.g. "mdi:home-assistant"' + ) + if len(value) > 63: + raise Invalid( + f"Icon string is too long ({len(value)} chars, max 63). " + "Icons are stored in PROGMEM with a 64-byte buffer limit." + ) + return value def sub_device_id(value: str | None) -> core.ID | None: diff --git a/esphome/core/entity_base.cpp b/esphome/core/entity_base.cpp index eafc04f92a4..2642b0094a5 100644 --- a/esphome/core/entity_base.cpp +++ b/esphome/core/entity_base.cpp @@ -1,6 +1,7 @@ #include "esphome/core/entity_base.h" #include "esphome/core/application.h" #include "esphome/core/helpers.h" +#include "esphome/core/progmem.h" #include "esphome/core/string_ref.h" namespace esphome { @@ -72,7 +73,24 @@ std::string EntityBase::get_unit_of_measurement() const { return std::string(this->get_unit_of_measurement_ref().c_str()); } -// Entity icon (from index) +// Entity icon — buffer-based API for PROGMEM safety on ESP8266 +const char *EntityBase::get_icon_to(std::span buffer) const { +#ifdef USE_ENTITY_ICON + const char *icon = entity_icon_lookup(this->icon_idx_); +#else + const char *icon = entity_icon_lookup(0); +#endif +#ifdef USE_ESP8266 + ESPHOME_strncpy_P(buffer.data(), icon, buffer.size() - 1); + buffer[buffer.size() - 1] = '\0'; + return buffer.data(); +#else + return icon; +#endif +} + +#ifndef USE_ESP8266 +// Deprecated icon accessors — not available on ESP8266 (rodata is RAM) StringRef EntityBase::get_icon_ref() const { #ifdef USE_ENTITY_ICON return StringRef(entity_icon_lookup(this->icon_idx_)); @@ -81,6 +99,7 @@ StringRef EntityBase::get_icon_ref() const { #endif } std::string EntityBase::get_icon() const { return std::string(this->get_icon_ref().c_str()); } +#endif // !USE_ESP8266 // Entity Object ID - computed on-demand from name std::string EntityBase::get_object_id() const { @@ -154,8 +173,10 @@ ESPPreferenceObject EntityBase::make_entity_preference_(size_t size, uint32_t ve #ifdef USE_ENTITY_ICON void log_entity_icon(const char *tag, const char *prefix, const EntityBase &obj) { - if (!obj.get_icon_ref().empty()) { - ESP_LOGCONFIG(tag, "%s Icon: '%s'", prefix, obj.get_icon_ref().c_str()); + char icon_buf[MAX_ICON_LENGTH]; + const char *icon = obj.get_icon_to(icon_buf); + if (icon[0] != '\0') { + ESP_LOGCONFIG(tag, "%s Icon: '%s'", prefix, icon); } } #endif diff --git a/esphome/core/entity_base.h b/esphome/core/entity_base.h index 042eebb40f3..6b6fe838346 100644 --- a/esphome/core/entity_base.h +++ b/esphome/core/entity_base.h @@ -36,6 +36,10 @@ static constexpr size_t OBJECT_ID_MAX_LEN = 128; // Maximum state length that Home Assistant will accept without raising ValueError static constexpr size_t MAX_STATE_LEN = 255; +// Maximum icon string buffer size (63 chars + null terminator) +// Icons are stored in PROGMEM; on ESP8266 they must be copied to a stack buffer. +static constexpr size_t MAX_ICON_LENGTH = 64; + enum EntityCategory : uint8_t { ENTITY_CATEGORY_NONE = 0, ENTITY_CATEGORY_CONFIG = 1, @@ -124,12 +128,31 @@ class EntityBase { "2026.3.0") std::string get_unit_of_measurement() const; - // Get/set this entity's icon - ESPDEPRECATED( - "Use get_icon_ref() instead for better performance (avoids string copy). Will be removed in ESPHome 2026.5.0", - "2025.11.0") - std::string get_icon() const; + // Get this entity's icon into a stack buffer. + // On ESP32: returns pointer to PROGMEM string directly (buffer unused). + // On ESP8266: copies from PROGMEM to buffer, returns buffer pointer. + const char *get_icon_to(std::span buffer) const; + +#ifdef USE_ESP8266 + // On ESP8266, rodata is RAM. Icons are in PROGMEM and cannot be accessed + // directly as const char*. Use get_icon_to() with a stack buffer instead. + template StringRef get_icon_ref() const { + static_assert(!sizeof(T), + "get_icon_ref() unavailable on ESP8266 (rodata is RAM). Use get_icon_to() with a stack buffer."); + return StringRef(""); + } + template std::string get_icon() const { + static_assert(!sizeof(T), + "get_icon() unavailable on ESP8266 (rodata is RAM). Use get_icon_to() with a stack buffer."); + return ""; + } +#else + // Deprecated: use get_icon_to() instead. Icons are in PROGMEM. + ESPDEPRECATED("Use get_icon_to() instead. Will be removed in ESPHome 2026.9.0", "2026.3.0") StringRef get_icon_ref() const; + ESPDEPRECATED("Use get_icon_to() instead. Will be removed in ESPHome 2026.9.0", "2026.3.0") + std::string get_icon() const; +#endif #ifdef USE_DEVICES // Get/set this entity's device id diff --git a/esphome/core/entity_helpers.py b/esphome/core/entity_helpers.py index 551e35df65c..9579855b11c 100644 --- a/esphome/core/entity_helpers.py +++ b/esphome/core/entity_helpers.py @@ -78,6 +78,8 @@ def _generate_category_code( table_var: str, lookup_fn: str, strings: dict[str, int], + *, + progmem_strings: bool = False, ) -> str: """Generate C++ code for one string category (PROGMEM pointer table + lookup). @@ -85,14 +87,37 @@ def _generate_category_code( in flash (via PROGMEM) and read with progmem_read_ptr(). String literals themselves remain in RAM but benefit from linker string deduplication. Index 0 means "not set" and returns empty string. + + When progmem_strings=True, each string is declared as a separate PROGMEM + char array. This ensures the string data itself is in flash on ESP8266 + (where .rodata is RAM). On other platforms PROGMEM is a no-op. """ if not strings: return "" sorted_strings = sorted(strings.items(), key=lambda x: x[1]) - entries = ", ".join(cpp_string_escape(s) for s, _ in sorted_strings) count = len(sorted_strings) + if progmem_strings: + # Emit individual PROGMEM char arrays so string data lives in flash + lines: list[str] = [] + var_names: list[str] = [] + for i, (s, _) in enumerate(sorted_strings): + var_name = f"{table_var}_STR_{i}" + var_names.append(var_name) + lines.append( + f"static const char {var_name}[] PROGMEM = {cpp_string_escape(s)};" + ) + entries = ", ".join(var_names) + lines.append(f"static const char *const {table_var}[] PROGMEM = {{{entries}}};") + lines.append(f"const char *{lookup_fn}(uint8_t index) {{") + lines.append(f' if (index == 0 || index > {count}) return "";') + lines.append(f" return progmem_read_ptr(&{table_var}[index - 1]);") + lines.append("}") + return "\n".join(lines) + "\n" + + entries = ", ".join(cpp_string_escape(s) for s, _ in sorted_strings) + return ( f"static const char *const {table_var}[] PROGMEM = {{{entries}}};\n" f"const char *{lookup_fn}(uint8_t index) {{\n" @@ -103,9 +128,9 @@ def _generate_category_code( _CATEGORY_CONFIGS = ( - ("ENTITY_DC_TABLE", "entity_device_class_lookup", "device_classes"), - ("ENTITY_UOM_TABLE", "entity_uom_lookup", "units"), - ("ENTITY_ICON_TABLE", "entity_icon_lookup", "icons"), + ("ENTITY_DC_TABLE", "entity_device_class_lookup", "device_classes", False), + ("ENTITY_UOM_TABLE", "entity_uom_lookup", "units", False), + ("ENTITY_ICON_TABLE", "entity_icon_lookup", "icons", True), ) @@ -117,8 +142,10 @@ async def _generate_tables_job() -> None: """ pool = _get_pool() parts = ["namespace esphome {"] - for table_var, lookup_fn, attr in _CATEGORY_CONFIGS: - code = _generate_category_code(table_var, lookup_fn, getattr(pool, attr)) + for table_var, lookup_fn, attr, progmem_strs in _CATEGORY_CONFIGS: + code = _generate_category_code( + table_var, lookup_fn, getattr(pool, attr), progmem_strings=progmem_strs + ) if code: parts.append(code) parts.append("} // namespace esphome") @@ -158,8 +185,15 @@ def register_unit_of_measurement(value: str) -> int: return _register_string(value, _get_pool().units, _MAX_UNITS, "unit_of_measurement") +_MAX_ICON_LENGTH = 63 # Max icon string length (64-byte buffer with null terminator) + + def register_icon(value: str) -> int: """Register an icon string and return its 1-based index.""" + if value and len(value) > _MAX_ICON_LENGTH: + raise ValueError( + f"Icon string too long ({len(value)} chars, max {_MAX_ICON_LENGTH}): '{value}'" + ) return _register_string(value, _get_pool().icons, _MAX_ICONS, "icon") From 1108511c63ed1d166a35ce31189df7df8b2e8a95 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 3 Mar 2026 08:06:55 -1000 Subject: [PATCH 2/5] move icons to progmem --- esphome/components/api/api_connection.h | 2 +- tests/unit_tests/core/test_entity_helpers.py | 17 +++++++++++++++++ tests/unit_tests/test_config_validation.py | 12 ++++++++++++ 3 files changed, 30 insertions(+), 1 deletion(-) diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 1282cb0a460..7f2bce757f6 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -349,7 +349,7 @@ class APIConnection final : public APIServerConnectionBase { // Set common EntityBase properties #ifdef USE_ENTITY_ICON char icon_buf[MAX_ICON_LENGTH]; - msg.icon = entity->get_icon_to(icon_buf); + msg.icon = StringRef(entity->get_icon_to(icon_buf)); #endif msg.disabled_by_default = entity->is_disabled_by_default(); msg.entity_category = static_cast(entity->get_entity_category()); diff --git a/tests/unit_tests/core/test_entity_helpers.py b/tests/unit_tests/core/test_entity_helpers.py index a5cfad5ab69..79bc3095b92 100644 --- a/tests/unit_tests/core/test_entity_helpers.py +++ b/tests/unit_tests/core/test_entity_helpers.py @@ -23,6 +23,7 @@ from esphome.core.entity_helpers import ( _setup_entity_impl, entity_duplicate_validator, get_base_entity_object_id, + register_icon, setup_entity, ) from esphome.cpp_generator import MockObj @@ -909,6 +910,22 @@ def test_register_string_overflow() -> None: _register_string("overflow", category, 3, "test") +def test_register_icon_max_length() -> None: + """Test register_icon rejects icons exceeding 63 characters.""" + # 63 chars should succeed + max_icon = "mdi:" + "a" * 59 # 63 total + idx = register_icon(max_icon) + assert idx > 0 + + # 64 chars should fail + too_long = "mdi:" + "a" * 60 # 64 total + with pytest.raises(ValueError, match="Icon string too long"): + register_icon(too_long) + + # Empty string returns 0 + assert register_icon("") == 0 + + @pytest.mark.asyncio async def test_setup_entity_with_entity_category( setup_test_environment: list[str], diff --git a/tests/unit_tests/test_config_validation.py b/tests/unit_tests/test_config_validation.py index 9602010ad30..c1849daf4ba 100644 --- a/tests/unit_tests/test_config_validation.py +++ b/tests/unit_tests/test_config_validation.py @@ -148,6 +148,18 @@ def test_icon__invalid(): config_validation.icon("foo") +def test_icon__max_length(): + """Test that icons exceeding 63 characters are rejected.""" + # Exactly 63 chars should pass + max_icon = "mdi:" + "a" * 59 # 63 chars total + assert config_validation.icon(max_icon) == max_icon + + # 64 chars should fail + too_long = "mdi:" + "a" * 60 # 64 chars total + with pytest.raises(Invalid, match="Icon string is too long"): + config_validation.icon(too_long) + + @pytest.mark.parametrize("value", ("True", "YES", "on", "enAblE", True)) def test_boolean__valid_true(value): assert config_validation.boolean(value) is True From 4a22afb79d2b08f0403fde4352f58632a1990c8f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 3 Mar 2026 08:15:56 -1000 Subject: [PATCH 3/5] fix: ensure icon empty string is PROGMEM, skip lookup when disabled - When USE_ENTITY_ICON is disabled, return "" directly without calling through the lookup table - When enabled, ensure the empty-string fallback (index 0 / out of range) is a PROGMEM char array so strncpy_P on ESP8266 is safe --- esphome/core/entity_base.cpp | 15 +++++++++------ esphome/core/entity_helpers.py | 5 ++++- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/esphome/core/entity_base.cpp b/esphome/core/entity_base.cpp index 2642b0094a5..63b93b82b88 100644 --- a/esphome/core/entity_base.cpp +++ b/esphome/core/entity_base.cpp @@ -49,7 +49,9 @@ void EntityBase::set_name(const char *name, uint32_t object_id_hash) { // Weak default lookup functions — overridden by generated code in main.cpp __attribute__((weak)) const char *entity_device_class_lookup(uint8_t) { return ""; } __attribute__((weak)) const char *entity_uom_lookup(uint8_t) { return ""; } -__attribute__((weak)) const char *entity_icon_lookup(uint8_t) { return ""; } +// Icon empty string must be PROGMEM — on ESP8266 callers use strncpy_P to read it +static const char ENTITY_ICON_EMPTY[] PROGMEM = ""; +__attribute__((weak)) const char *entity_icon_lookup(uint8_t) { return ENTITY_ICON_EMPTY; } // Entity device class (from index) StringRef EntityBase::get_device_class_ref() const { @@ -74,12 +76,12 @@ std::string EntityBase::get_unit_of_measurement() const { } // Entity icon — buffer-based API for PROGMEM safety on ESP8266 -const char *EntityBase::get_icon_to(std::span buffer) const { -#ifdef USE_ENTITY_ICON - const char *icon = entity_icon_lookup(this->icon_idx_); +const char *EntityBase::get_icon_to([[maybe_unused]] std::span buffer) const { +#ifndef USE_ENTITY_ICON + // No icons configured — skip lookup entirely + return ""; #else - const char *icon = entity_icon_lookup(0); -#endif + const char *icon = entity_icon_lookup(this->icon_idx_); #ifdef USE_ESP8266 ESPHOME_strncpy_P(buffer.data(), icon, buffer.size() - 1); buffer[buffer.size() - 1] = '\0'; @@ -87,6 +89,7 @@ const char *EntityBase::get_icon_to(std::span buffer) con #else return icon; #endif +#endif // USE_ENTITY_ICON } #ifndef USE_ESP8266 diff --git a/esphome/core/entity_helpers.py b/esphome/core/entity_helpers.py index 9579855b11c..c699ac3dda4 100644 --- a/esphome/core/entity_helpers.py +++ b/esphome/core/entity_helpers.py @@ -109,9 +109,12 @@ def _generate_category_code( f"static const char {var_name}[] PROGMEM = {cpp_string_escape(s)};" ) entries = ", ".join(var_names) + # Empty string must also be PROGMEM — on ESP8266, callers use strncpy_P + empty_var = f"{table_var}_EMPTY" + lines.append(f'static const char {empty_var}[] PROGMEM = "";') lines.append(f"static const char *const {table_var}[] PROGMEM = {{{entries}}};") lines.append(f"const char *{lookup_fn}(uint8_t index) {{") - lines.append(f' if (index == 0 || index > {count}) return "";') + lines.append(f" if (index == 0 || index > {count}) return {empty_var};") lines.append(f" return progmem_read_ptr(&{table_var}[index - 1]);") lines.append("}") return "\n".join(lines) + "\n" From 8335bb5f8efc07038830f243668e80ae168458c1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 3 Mar 2026 08:18:18 -1000 Subject: [PATCH 4/5] remove unnecessary PROGMEM empty string from weak default The weak default is only reached when USE_ENTITY_ICON is disabled, and get_icon_to() already short-circuits with return "" in that case. --- esphome/core/entity_base.cpp | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/esphome/core/entity_base.cpp b/esphome/core/entity_base.cpp index 63b93b82b88..ae84579f4a0 100644 --- a/esphome/core/entity_base.cpp +++ b/esphome/core/entity_base.cpp @@ -49,9 +49,7 @@ void EntityBase::set_name(const char *name, uint32_t object_id_hash) { // Weak default lookup functions — overridden by generated code in main.cpp __attribute__((weak)) const char *entity_device_class_lookup(uint8_t) { return ""; } __attribute__((weak)) const char *entity_uom_lookup(uint8_t) { return ""; } -// Icon empty string must be PROGMEM — on ESP8266 callers use strncpy_P to read it -static const char ENTITY_ICON_EMPTY[] PROGMEM = ""; -__attribute__((weak)) const char *entity_icon_lookup(uint8_t) { return ENTITY_ICON_EMPTY; } +__attribute__((weak)) const char *entity_icon_lookup(uint8_t) { return ""; } // Entity device class (from index) StringRef EntityBase::get_device_class_ref() const { From 4267d29cb50cede2d318bf02327d31c39820ed07 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 3 Mar 2026 08:19:30 -1000 Subject: [PATCH 5/5] simplify get_icon_to: single lookup call on non-ESP8266 - Non-ESP8266: just return entity_icon_lookup(idx) directly - ESP8266: short-circuit idx==0 to avoid strncpy_P on non-PROGMEM "" --- esphome/core/entity_base.cpp | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/esphome/core/entity_base.cpp b/esphome/core/entity_base.cpp index ae84579f4a0..f91e6613640 100644 --- a/esphome/core/entity_base.cpp +++ b/esphome/core/entity_base.cpp @@ -75,19 +75,21 @@ std::string EntityBase::get_unit_of_measurement() const { // Entity icon — buffer-based API for PROGMEM safety on ESP8266 const char *EntityBase::get_icon_to([[maybe_unused]] std::span buffer) const { -#ifndef USE_ENTITY_ICON - // No icons configured — skip lookup entirely - return ""; +#ifdef USE_ENTITY_ICON + const uint8_t idx = this->icon_idx_; #else - const char *icon = entity_icon_lookup(this->icon_idx_); + const uint8_t idx = 0; +#endif #ifdef USE_ESP8266 + if (idx == 0) + return ""; + const char *icon = entity_icon_lookup(idx); ESPHOME_strncpy_P(buffer.data(), icon, buffer.size() - 1); buffer[buffer.size() - 1] = '\0'; return buffer.data(); #else - return icon; + return entity_icon_lookup(idx); #endif -#endif // USE_ENTITY_ICON } #ifndef USE_ESP8266