diff --git a/esphome/config_validation.py b/esphome/config_validation.py index 79d7068838a..636aac17a61 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -2445,6 +2445,7 @@ def _validate_no_slash(value): # This ensures web server URL IDs fit in a 280-byte buffer: # domain(20) + "/" + device(120) + "/" + name(120) + null = 263 bytes # Note: Must be < 255 because web_server UrlMatch uses uint8_t for length fields +# Keep in sync with ESPHOME_ENTITY_NAME_MAX_LEN in esphome/core/entity_base.h NAME_MAX_LENGTH = 120 diff --git a/esphome/core/entity_base.cpp b/esphome/core/entity_base.cpp index c172188468f..990f84075d9 100644 --- a/esphome/core/entity_base.cpp +++ b/esphome/core/entity_base.cpp @@ -1,6 +1,5 @@ #include "esphome/core/entity_base.h" #include "esphome/core/application.h" -#include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" #include "esphome/core/progmem.h" @@ -11,11 +10,11 @@ namespace esphome { ESPHOME_LOG_TAG(TAG, "entity_base"); void EntityBase::configure_entity_(const char *name, uint32_t object_id_hash, uint32_t entity_fields) { - this->name_ = StringRef(name, ESPHOME_strlen_P(name)); + this->name_ = ProgmemStringRef(name, ESPHOME_strlen_P(name)); if (this->name_.empty()) { #ifdef USE_DEVICES if (this->device_ != nullptr) { - this->name_ = StringRef(this->device_->get_name()); + this->name_ = ProgmemStringRef(StringRef(this->device_->get_name())); } else #endif { @@ -25,10 +24,10 @@ void EntityBase::configure_entity_(const char *name, uint32_t object_id_hash, ui const auto &friendly = App.get_friendly_name(); if (App.is_name_add_mac_suffix_enabled()) { // MAC suffix enabled - use friendly_name directly (even if empty) for compatibility - this->name_ = friendly; + this->name_ = ProgmemStringRef(friendly); } else { // No MAC suffix - fallback to device name if friendly_name is empty - this->name_ = !friendly.empty() ? friendly : App.get_name(); + this->name_ = ProgmemStringRef(!friendly.empty() ? friendly : App.get_name()); } } this->flags_.has_own_name = false; @@ -125,23 +124,6 @@ void EntityBase::calc_object_id_() { this->object_id_hash_ = fnv1_hash_object_id(name.c_str(), name.size()); } -size_t EntityBase::write_name_to(char *buf, size_t buf_size) const { - size_t len = std::min(this->name_.size(), buf_size - 1); - progmem_memcpy(buf, this->name_.progmem_ptr(), len); - buf[len] = '\0'; - return len; -} - -#ifdef USE_ESP8266 -StringRef EntityBase::get_name_to(std::span buffer) const { - return StringRef(buffer.data(), this->write_name_to(buffer.data(), buffer.size())); -} - -bool EntityBase::name_equals(const StringRef &other) const { - return other.size() == this->name_.size() && memcmp_P(other.c_str(), this->name_.progmem_ptr(), other.size()) == 0; -} -#endif - size_t EntityBase::write_object_id_to(char *buf, size_t buf_size) const { size_t len = this->write_name_to(buf, buf_size); for (size_t i = 0; i < len; i++) { diff --git a/esphome/core/entity_base.h b/esphome/core/entity_base.h index 448cb9aa052..f4aeceec50b 100644 --- a/esphome/core/entity_base.h +++ b/esphome/core/entity_base.h @@ -31,8 +31,11 @@ static constexpr size_t ESPHOME_DEVICE_NAME_MAX_LEN = 31; // esphome/core/config.py static constexpr size_t ESPHOME_FRIENDLY_NAME_MAX_LEN = 120; -// Buffer size for EntityBase::get_name_to(); entity names are capped by NAME_MAX_LENGTH in config_validation.py -static constexpr size_t ENTITY_NAME_BUF_SIZE = ESPHOME_FRIENDLY_NAME_MAX_LEN + 1; +// Maximum entity name length - keep in sync with NAME_MAX_LENGTH in esphome/config_validation.py +static constexpr size_t ESPHOME_ENTITY_NAME_MAX_LEN = 120; + +// Buffer size for EntityBase::get_name_to() +static constexpr size_t ENTITY_NAME_BUF_SIZE = ESPHOME_ENTITY_NAME_MAX_LEN + 1; // Maximum domain length (longest: "alarm_control_panel" = 19) static constexpr size_t ESPHOME_DOMAIN_MAX_LEN = 20; @@ -75,20 +78,22 @@ class EntityBase { template StringRef get_name() const { static_assert(sizeof(T) == 0, "get_name() unavailable on ESP8266 (name is in flash). " "Use get_name_to() with a stack buffer, or get_log_name() for logging."); - return StringRef(this->name_.progmem_ptr(), this->name_.size()); + return {}; } /// Get the name of this Entity, copied out of flash into buffer. - StringRef get_name_to(std::span buffer) const; - bool name_equals(const StringRef &other) const; + StringRef get_name_to(std::span buffer) const { + return StringRef(buffer.data(), this->name_.write_to(buffer.data(), buffer.size())); + } #else ESPDEPRECATED("Use get_name_to() or get_log_name() instead. Will be removed in ESPHome 2027.5.0", "2026.11.0") - StringRef get_name() const { return this->name_ref_(); } - StringRef get_name_to(std::span /*buffer*/) const { return this->name_ref_(); } - bool name_equals(const StringRef &other) const { return this->name_ref_() == other; } + StringRef get_name() const { return this->name_.ram_ref(); } + StringRef get_name_to(std::span /*buffer*/) const { return this->name_.ram_ref(); } #endif + bool name_equals(const StringRef &other) const { return this->name_.equals(other); } + /// Copy the name into buf (null terminated), returns its length. - size_t write_name_to(char *buf, size_t buf_size) const; + size_t write_name_to(char *buf, size_t buf_size) const { return this->name_.write_to(buf, buf_size); } /// Get the name for a "%s" log argument (LOG_STR_ARG). const LogString *get_log_name() const { return reinterpret_cast(this->name_.progmem_ptr()); } @@ -234,9 +239,6 @@ class EntityBase { ESPPreferenceObject make_entity_preference_(size_t size, uint32_t version); void calc_object_id_(); -#ifndef USE_ESP8266 - StringRef name_ref_() const { return StringRef(this->name_.progmem_ptr(), this->name_.size()); } -#endif ProgmemStringRef name_; uint32_t object_id_hash_{}; diff --git a/esphome/core/entity_helpers.py b/esphome/core/entity_helpers.py index d969c1a07e0..0f98940967f 100644 --- a/esphome/core/entity_helpers.py +++ b/esphome/core/entity_helpers.py @@ -312,7 +312,9 @@ def finalize_entity_strings(var: MockObj, config: ConfigType) -> None: ``App.register_(var, name, hash, packed)``. Otherwise falls back to a standalone ``var->configure_entity_(name, hash, packed)``. """ - entity_name = config[_KEY_ENTITY_NAME] + entity_name = RawExpression( + f"ESPHOME_PSTR({cpp_string_escape(config[_KEY_ENTITY_NAME])})" + ) object_id_hash = config[_KEY_OBJECT_ID_HASH] dc_idx = config.get(_KEY_DC_IDX, 0) uom_idx = config.get(_KEY_UOM_IDX, 0) @@ -330,7 +332,6 @@ def finalize_entity_strings(var: MockObj, config: ConfigType) -> None: ) # Build inline comment describing the packed flags for readability comment = _describe_packed_flags(config, entity_category) - entity_name = RawExpression(f"ESPHOME_PSTR({cpp_string_escape(entity_name)})") register_method = config.get(_KEY_REGISTER_METHOD) if register_method is not None: expr = getattr(App, f"register_{register_method}")( diff --git a/esphome/core/string_ref.h b/esphome/core/string_ref.h index 7ca862baa6e..40588b04e58 100644 --- a/esphome/core/string_ref.h +++ b/esphome/core/string_ref.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -222,20 +223,43 @@ inline std::string operator+(const std::string &lhs, const StringRef &rhs) { str.append(rhs.c_str(), rhs.size()); return str; } -// String conversion functions for ADL compatibility (allows stoi(x) where x is StringRef) -// Must be in esphome namespace for ADL to find them. Uses strtol/strtod directly to avoid heap allocation. /// A string that may live in flash (on ESP8266): pointer and length, with no byte-reading operations. -/// Read the contents with the progmem helpers; progmem_ptr() is also safe as a "%s" log argument. +/// progmem_ptr() is safe as a "%s" log argument; read the contents with write_to() or equals(). class ProgmemStringRef { public: constexpr ProgmemStringRef() = default; constexpr ProgmemStringRef(const char *s, size_t len) : base_(s), len_(len) {} - constexpr ProgmemStringRef(const StringRef &s) : base_(s.c_str()), len_(s.size()) {} // NOLINT + constexpr explicit ProgmemStringRef(const StringRef &s) : base_(s.c_str()), len_(s.size()) {} constexpr const char *progmem_ptr() const { return this->base_; } constexpr size_t size() const { return this->len_; } constexpr bool empty() const { return this->len_ == 0; } + /// Copy into buf (null terminated, truncated to fit), returns the length copied. + size_t write_to(char *buf, size_t buf_size) const { + size_t len = std::min(this->len_, buf_size - 1); +#ifdef USE_ESP8266 + memcpy_P(buf, this->base_, len); +#else + std::memcpy(buf, this->base_, len); +#endif + buf[len] = '\0'; + return len; + } + + bool equals(const StringRef &other) const { +#ifdef USE_ESP8266 + return other.size() == this->len_ && memcmp_P(other.c_str(), this->base_, this->len_) == 0; +#else + return other == StringRef(this->base_, this->len_); +#endif + } + +#ifndef USE_ESP8266 + /// The string is in RAM here, so it can be viewed directly. + constexpr StringRef ram_ref() const { return StringRef(this->base_, this->len_); } +#endif + // Remove before 2027.5.0 (helpers.h's ESPDEPRECATED is not reachable from here) [[deprecated( "May point to flash; use progmem_ptr() or the owner's accessors. Removed in 2027.5.0")]] constexpr const char * @@ -243,11 +267,13 @@ class ProgmemStringRef { return this->base_; } - protected: + private: const char *base_{""}; size_t len_{0}; }; +// String conversion functions for ADL compatibility (allows stoi(x) where x is StringRef) +// Must be in esphome namespace for ADL to find them. Uses strtol/strtod directly to avoid heap allocation. namespace internal { // NOLINTBEGIN(google-runtime-int) template inline R parse_number(const StringRef &str, size_t *pos, F conv) { diff --git a/tests/component_tests/helpers.py b/tests/component_tests/helpers.py index 458c969788b..26051fa9f15 100644 --- a/tests/component_tests/helpers.py +++ b/tests/component_tests/helpers.py @@ -14,15 +14,12 @@ def extract_packed_value(main_cpp: str, var_name: str) -> int: combined form ``App.register_(var, name, hash, packed)``. """ escaped_var = re.escape(var_name) + name_re = r'ESPHOME_PSTR\("(?:\\.|[^"\\])*"\)' legacy_pattern = ( - rf"{escaped_var}->configure_entity_\(" - r'ESPHOME_PSTR\("(?:\\.|[^"\\])*"\)' - r",\s*\w+,\s*(\d+)\)" + rf"{escaped_var}->configure_entity_\(" + name_re + r",\s*\w+,\s*(\d+)\)" ) combined_pattern = ( - rf"App\.register_\w+\(\s*{escaped_var}\s*,\s*" - r'ESPHOME_PSTR\("(?:\\.|[^"\\])*"\)' - r",\s*\w+,\s*(\d+)\)" + rf"App\.register_\w+\(\s*{escaped_var}\s*,\s*" + name_re + r",\s*\w+,\s*(\d+)\)" ) match = re.search(combined_pattern, main_cpp) or re.search(legacy_pattern, main_cpp) assert match, f"configure call not found for {var_name}" diff --git a/tests/component_tests/text_sensor/test_text_sensor.py b/tests/component_tests/text_sensor/test_text_sensor.py index 3cd08d48d21..6d640afe3c5 100644 --- a/tests/component_tests/text_sensor/test_text_sensor.py +++ b/tests/component_tests/text_sensor/test_text_sensor.py @@ -28,18 +28,9 @@ def test_text_sensor_sets_mandatory_fields(generate_main): main_cpp = generate_main("tests/component_tests/text_sensor/test_text_sensor.yaml") # Then - assert ( - 'App.register_text_sensor(ts_1, ESPHOME_PSTR("Template Text Sensor 1"),' - in main_cpp - ) - assert ( - 'App.register_text_sensor(ts_2, ESPHOME_PSTR("Template Text Sensor 2"),' - in main_cpp - ) - assert ( - 'App.register_text_sensor(ts_3, ESPHOME_PSTR("Template Text Sensor 3"),' - in main_cpp - ) + for i in (1, 2, 3): + name = f'ESPHOME_PSTR("Template Text Sensor {i}")' + assert f"App.register_text_sensor(ts_{i}, {name}," in main_cpp def test_text_sensor_config_value_internal_set(generate_main): diff --git a/tests/unit_tests/core/test_entity_helpers.py b/tests/unit_tests/core/test_entity_helpers.py index f5a0696e355..d4a3e5705a0 100644 --- a/tests/unit_tests/core/test_entity_helpers.py +++ b/tests/unit_tests/core/test_entity_helpers.py @@ -38,11 +38,8 @@ from esphome.helpers import fnv1_hash, sanitize, snake_case from .common import load_config_from_fixture -# Pre-compiled regex pattern for extracting names from configure_entity_/set_name calls -# Matches: .configure_entity_(ESPHOME_PSTR("name"), ...) or .set_name("name", ...) -ENTITY_NAME_PATTERN = re.compile( - r'\.(?:configure_entity_|set_name)\(ESPHOME_PSTR\(["\']([^"\']*)["\']' -) +# Extracts the name from .configure_entity_(ESPHOME_PSTR("name"), ...) +ENTITY_NAME_PATTERN = re.compile(r'\.configure_entity_\(ESPHOME_PSTR\("([^"]*)"') FIXTURES_DIR = Path(__file__).parent.parent / "fixtures" / "core" / "entity_helpers"