From 7944fe69935aeb79c76c8938ff287f3fda6b80a5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 22 Dec 2025 15:13:59 -1000 Subject: [PATCH 01/25] [core] Deprecate get_object_id() and migrate remaining usages to get_object_id_to() --- esphome/components/pid/pid_climate.cpp | 10 ++++++---- esphome/components/prometheus/prometheus_handler.cpp | 7 ++++++- esphome/core/entity_base.h | 9 +++++++++ 3 files changed, 21 insertions(+), 5 deletions(-) diff --git a/esphome/components/pid/pid_climate.cpp b/esphome/components/pid/pid_climate.cpp index fd74eabd87..25aae7c4cb 100644 --- a/esphome/components/pid/pid_climate.cpp +++ b/esphome/components/pid/pid_climate.cpp @@ -1,4 +1,5 @@ #include "pid_climate.h" +#include "esphome/core/entity_base.h" #include "esphome/core/log.h" namespace esphome { @@ -162,14 +163,16 @@ void PIDClimate::start_autotune(std::unique_ptr &&autotune) { float min_value = this->supports_cool_() ? -1.0f : 0.0f; float max_value = this->supports_heat_() ? 1.0f : 0.0f; this->autotuner_->config(min_value, max_value); - this->autotuner_->set_autotuner_id(this->get_object_id()); + char object_id_buf[OBJECT_ID_MAX_LEN]; + StringRef object_id = this->get_object_id_to(object_id_buf); + this->autotuner_->set_autotuner_id(std::string(object_id.c_str())); ESP_LOGI(TAG, "%s: Autotune has started. This can take a long time depending on the " "responsiveness of your system. Your system " "output will be altered to deliberately oscillate above and below the setpoint multiple times. " "Until your sensor provides a reading, the autotuner may display \'nan\'", - this->get_object_id().c_str()); + object_id.c_str()); this->set_interval("autotune-progress", 10000, [this]() { if (this->autotuner_ != nullptr && !this->autotuner_->is_finished()) @@ -177,8 +180,7 @@ void PIDClimate::start_autotune(std::unique_ptr &&autotune) { }); if (mode != climate::CLIMATE_MODE_HEAT_COOL) { - ESP_LOGW(TAG, "%s: !!! For PID autotuner you need to set AUTO (also called heat/cool) mode!", - this->get_object_id().c_str()); + ESP_LOGW(TAG, "%s: !!! For PID autotuner you need to set AUTO (also called heat/cool) mode!", object_id.c_str()); } } diff --git a/esphome/components/prometheus/prometheus_handler.cpp b/esphome/components/prometheus/prometheus_handler.cpp index 4b5d834ebf..95ddc87b7e 100644 --- a/esphome/components/prometheus/prometheus_handler.cpp +++ b/esphome/components/prometheus/prometheus_handler.cpp @@ -112,7 +112,12 @@ void PrometheusHandler::handleRequest(AsyncWebServerRequest *req) { std::string PrometheusHandler::relabel_id_(EntityBase *obj) { auto item = relabel_map_id_.find(obj); - return item == relabel_map_id_.end() ? obj->get_object_id() : item->second; + if (item != relabel_map_id_.end()) { + return item->second; + } + char object_id_buf[OBJECT_ID_MAX_LEN]; + StringRef object_id = obj->get_object_id_to(object_id_buf); + return std::string(object_id.c_str()); } std::string PrometheusHandler::relabel_name_(EntityBase *obj) { diff --git a/esphome/core/entity_base.h b/esphome/core/entity_base.h index eb1ba46c94..93f989934a 100644 --- a/esphome/core/entity_base.h +++ b/esphome/core/entity_base.h @@ -33,6 +33,15 @@ class EntityBase { bool has_own_name() const { return this->flags_.has_own_name; } // Get the sanitized name of this Entity as an ID. + // Deprecated: object_id mangles names and all object_id methods are planned for removal. + // See https://github.com/esphome/backlog/issues/76 + // Now is the time to stop using object_id entirely. If you still need it temporarily, + // use get_object_id_to() which will remain available longer but will also eventually be removed. + ESPDEPRECATED("object_id mangles names and all object_id methods are planned for removal " + "(see https://github.com/esphome/backlog/issues/76). " + "Now is the time to stop using object_id. If still needed, use get_object_id_to() " + "which will remain available longer. get_object_id() will be removed in 2026.7.0", + "2025.12.0") std::string get_object_id() const; void set_object_id(const char *object_id); From 452246e1c599f6a04b243cc415c313d2a8fe5065 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 22 Dec 2025 20:01:57 -1000 Subject: [PATCH 02/25] [core] Remove object_id RAM storage - no longer in hot path after #12627 --- esphome/core/entity_base.cpp | 74 ++++++------------ esphome/core/entity_base.h | 11 +-- esphome/core/entity_helpers.py | 35 +++------ esphome/core/helpers.h | 14 ++++ esphome/helpers.py | 28 +++++++ .../fixtures/fnv1_hash_object_id.yaml | 76 +++++++++++++++++++ tests/integration/test_fnv1_hash_object_id.py | 75 ++++++++++++++++++ tests/unit_tests/test_helpers.py | 71 +++++++++++++++++ 8 files changed, 302 insertions(+), 82 deletions(-) create mode 100644 tests/integration/fixtures/fnv1_hash_object_id.yaml create mode 100644 tests/integration/test_fnv1_hash_object_id.py diff --git a/esphome/core/entity_base.cpp b/esphome/core/entity_base.cpp index b7616a9ad3..f5d563dead 100644 --- a/esphome/core/entity_base.cpp +++ b/esphome/core/entity_base.cpp @@ -9,7 +9,8 @@ static const char *const TAG = "entity_base"; // Entity Name const StringRef &EntityBase::get_name() const { return this->name_; } -void EntityBase::set_name(const char *name) { +void EntityBase::set_name(const char *name) { this->set_name(name, 0); } +void EntityBase::set_name(const char *name, uint32_t object_id_hash) { this->name_ = StringRef(name); if (this->name_.empty()) { #ifdef USE_DEVICES @@ -21,8 +22,16 @@ void EntityBase::set_name(const char *name) { this->name_ = StringRef(App.get_friendly_name()); } this->flags_.has_own_name = false; + // Dynamic name - must calculate hash at runtime + this->calc_object_id_(); } else { this->flags_.has_own_name = true; + // Static name - use pre-computed hash if provided + if (object_id_hash != 0) { + this->object_id_hash_ = object_id_hash; + } else { + this->calc_object_id_(); + } } } @@ -45,69 +54,34 @@ void EntityBase::set_icon(const char *icon) { #endif } -// Check if the object_id is dynamic (changes with MAC suffix) -bool EntityBase::is_object_id_dynamic_() const { - return !this->flags_.has_own_name && App.is_name_add_mac_suffix_enabled(); -} - -// Entity Object ID +// Entity Object ID - computed on-demand from name std::string EntityBase::get_object_id() const { - // Check if `App.get_friendly_name()` is constant or dynamic. - if (this->is_object_id_dynamic_()) { - // `App.get_friendly_name()` is dynamic. - return str_sanitize(str_snake_case(App.get_friendly_name())); - } - // `App.get_friendly_name()` is constant. - return this->object_id_c_str_ == nullptr ? "" : this->object_id_c_str_; -} -void EntityBase::set_object_id(const char *object_id) { - this->object_id_c_str_ = object_id; - this->calc_object_id_(); -} - -void EntityBase::set_name_and_object_id(const char *name, const char *object_id) { - this->set_name(name); - this->object_id_c_str_ = object_id; - this->calc_object_id_(); -} - -// Calculate Object ID Hash from Entity Name -void EntityBase::calc_object_id_() { char buf[OBJECT_ID_MAX_LEN]; - StringRef object_id = this->get_object_id_to(buf); - this->object_id_hash_ = fnv1_hash(object_id.c_str()); + size_t len = this->write_object_id_to(buf, sizeof(buf)); + return std::string(buf, len); } -// Format dynamic object_id: sanitized snake_case of friendly_name -static size_t format_dynamic_object_id(char *buf, size_t buf_size) { - const std::string &name = App.get_friendly_name(); - size_t len = std::min(name.size(), buf_size - 1); - for (size_t i = 0; i < len; i++) { - buf[i] = to_sanitized_char(to_snake_case_char(name[i])); - } - buf[len] = '\0'; - return len; +// Calculate Object ID Hash directly from name using snake_case + sanitize +void EntityBase::calc_object_id_() { + this->object_id_hash_ = fnv1_hash_object_id(this->name_.c_str(), this->name_.size()); } size_t EntityBase::write_object_id_to(char *buf, size_t buf_size) const { - if (this->is_object_id_dynamic_()) { - return format_dynamic_object_id(buf, buf_size); + size_t len = std::min(this->name_.size(), buf_size - 1); + for (size_t i = 0; i < len; i++) { + buf[i] = to_sanitized_char(to_snake_case_char(this->name_[i])); } - const char *src = this->object_id_c_str_ == nullptr ? "" : this->object_id_c_str_; - size_t len = strlen(src); - if (len >= buf_size) - len = buf_size - 1; - memcpy(buf, src, len); buf[len] = '\0'; return len; } StringRef EntityBase::get_object_id_to(std::span buf) const { - if (this->is_object_id_dynamic_()) { - size_t len = format_dynamic_object_id(buf.data(), buf.size()); - return StringRef(buf.data(), len); + size_t len = std::min(this->name_.size(), buf.size() - 1); + for (size_t i = 0; i < len; i++) { + buf[i] = to_sanitized_char(to_snake_case_char(this->name_[i])); } - return this->object_id_c_str_ == nullptr ? StringRef() : StringRef(this->object_id_c_str_); + buf[len] = '\0'; + return StringRef(buf.data(), len); } uint32_t EntityBase::get_object_id_hash() { return this->object_id_hash_; } diff --git a/esphome/core/entity_base.h b/esphome/core/entity_base.h index 93f989934a..678040a04e 100644 --- a/esphome/core/entity_base.h +++ b/esphome/core/entity_base.h @@ -28,6 +28,9 @@ class EntityBase { // Get/set the name of this Entity const StringRef &get_name() const; void set_name(const char *name); + /// Set name with pre-computed object_id hash (avoids runtime hash calculation) + /// Use hash=0 for dynamic names that need runtime calculation + void set_name(const char *name, uint32_t object_id_hash); // Get whether this Entity has its own name or it should use the device friendly_name. bool has_own_name() const { return this->flags_.has_own_name; } @@ -43,10 +46,6 @@ class EntityBase { "which will remain available longer. get_object_id() will be removed in 2026.7.0", "2025.12.0") std::string get_object_id() const; - void set_object_id(const char *object_id); - - // Set both name and object_id in one call (reduces generated code size) - void set_name_and_object_id(const char *name, const char *object_id); // Get the unique Object ID of this Entity uint32_t get_object_id_hash(); @@ -140,11 +139,7 @@ class EntityBase { protected: void calc_object_id_(); - /// Check if the object_id is dynamic (changes with MAC suffix) - bool is_object_id_dynamic_() const; - StringRef name_; - const char *object_id_c_str_{nullptr}; #ifdef USE_ENTITY_ICON const char *icon_c_str_{nullptr}; #endif diff --git a/esphome/core/entity_helpers.py b/esphome/core/entity_helpers.py index f360b4d809..f5e57300c8 100644 --- a/esphome/core/entity_helpers.py +++ b/esphome/core/entity_helpers.py @@ -15,7 +15,7 @@ from esphome.const import ( from esphome.core import CORE, ID from esphome.cpp_generator import MockObj, add, get_variable import esphome.final_validate as fv -from esphome.helpers import sanitize, snake_case +from esphome.helpers import fnv1_hash_object_id, sanitize, snake_case from esphome.types import ConfigType, EntityMetadata _LOGGER = logging.getLogger(__name__) @@ -75,34 +75,21 @@ async def setup_entity(var: MockObj, config: ConfigType, platform: str) -> None: config: Configuration dictionary containing entity settings platform: The platform name (e.g., "sensor", "binary_sensor") """ - # Get device info - device_name: str | None = None + # Set device if configured device_id_obj: ID | None if device_id_obj := config.get(CONF_DEVICE_ID): device: MockObj = await get_variable(device_id_obj) add(var.set_device(device)) - # Get device name for object ID calculation - device_name = device_id_obj.id - # Calculate base object_id using the same logic as C++ - # This must match the C++ behavior in esphome/core/entity_base.cpp - base_object_id = get_base_entity_object_id( - config[CONF_NAME], CORE.friendly_name, device_name - ) - - if not config[CONF_NAME]: - _LOGGER.debug( - "Entity has empty name, using '%s' as object_id base", base_object_id - ) - - # Set both name and object_id in one call to reduce generated code size - add(var.set_name_and_object_id(config[CONF_NAME], base_object_id)) - _LOGGER.debug( - "Setting object_id '%s' for entity '%s' on platform '%s'", - base_object_id, - config[CONF_NAME], - platform, - ) + # Set the entity name with pre-computed object_id hash + # For entities with a name, we pre-compute the hash to avoid runtime calculation + # For empty names (use device friendly_name), pass 0 to compute at runtime + entity_name = config[CONF_NAME] + if entity_name: + object_id_hash = fnv1_hash_object_id(entity_name) + add(var.set_name(entity_name, object_id_hash)) + else: + add(var.set_name(entity_name, 0)) # Only set disabled_by_default if True (default is False) if config[CONF_DISABLED_BY_DEFAULT]: add(var.set_disabled_by_default(True)) diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index 769041160c..3bbda5f8dd 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -529,6 +529,20 @@ constexpr char to_sanitized_char(char c) { /// Sanitizes the input string by removing all characters but alphanumerics, dashes and underscores. std::string str_sanitize(const std::string &str); +/// Calculate FNV-1 hash of a string while applying snake_case + sanitize transformations. +/// This computes object_id hashes directly from names without creating an intermediate buffer. +/// IMPORTANT: Must match Python fnv1_hash_object_id() in esphome/helpers.py. +/// If you modify this function, update the Python version and tests in both places. +inline uint32_t fnv1_hash_object_id(const char *str, size_t len) { + uint32_t hash = FNV1_OFFSET_BASIS; + for (size_t i = 0; i < len; i++) { + hash *= FNV1_PRIME; + // Apply snake_case (space->underscore, uppercase->lowercase) then sanitize + hash ^= static_cast(to_sanitized_char(to_snake_case_char(str[i]))); + } + return hash; +} + /// snprintf-like function returning std::string of maximum length \p len (excluding null terminator). std::string __attribute__((format(printf, 1, 3))) str_snprintf(const char *fmt, size_t len, ...); diff --git a/esphome/helpers.py b/esphome/helpers.py index d1623d1d3c..18f459d3ee 100644 --- a/esphome/helpers.py +++ b/esphome/helpers.py @@ -70,6 +70,34 @@ def fnv1a_32bit_hash(string: str) -> int: return hash_value +def fnv1_hash_object_id(name: str) -> int: + """Compute FNV-1 hash of name with snake_case + sanitize transformations. + + IMPORTANT: This must match the C++ fnv1_hash_object_id() in esphome/core/helpers.h. + If you modify this function, update the C++ version and tests in both places. + + Used for pre-computing entity object_id hashes at code generation time. + """ + hash_value = 2166136261 # FNV1_OFFSET_BASIS + for char in name: + # Apply snake_case: space -> underscore, uppercase -> lowercase + if char == " ": + c = "_" + elif "A" <= char <= "Z": + c = chr(ord(char) + 32) # lowercase + else: + c = char + # Apply sanitize: keep alphanumerics, dash, underscore; replace others with _ + if not ( + c in {"-", "_"} or "0" <= c <= "9" or "a" <= c <= "z" or "A" <= c <= "Z" + ): + c = "_" + # FNV-1: multiply then XOR + hash_value = (hash_value * 16777619) & 0xFFFFFFFF + hash_value ^= ord(c) + return hash_value + + def strip_accents(value: str) -> str: """Remove accents from a string.""" import unicodedata diff --git a/tests/integration/fixtures/fnv1_hash_object_id.yaml b/tests/integration/fixtures/fnv1_hash_object_id.yaml new file mode 100644 index 0000000000..2097b2fbf9 --- /dev/null +++ b/tests/integration/fixtures/fnv1_hash_object_id.yaml @@ -0,0 +1,76 @@ +esphome: + name: fnv1-hash-object-id-test + platformio_options: + build_flags: + - "-DDEBUG" + on_boot: + - lambda: |- + using esphome::fnv1_hash_object_id; + + // Test basic lowercase (hash matches Python fnv1_hash_object_id("foo")) + uint32_t hash_foo = fnv1_hash_object_id("foo", 3); + if (hash_foo == 0x408f5e13) { + ESP_LOGI("FNV1_OID", "foo PASSED"); + } else { + ESP_LOGE("FNV1_OID", "foo FAILED: 0x%08x != 0x408f5e13", hash_foo); + } + + // Test uppercase conversion (should match lowercase) + uint32_t hash_Foo = fnv1_hash_object_id("Foo", 3); + if (hash_Foo == 0x408f5e13) { + ESP_LOGI("FNV1_OID", "upper PASSED"); + } else { + ESP_LOGE("FNV1_OID", "upper FAILED: 0x%08x != 0x408f5e13", hash_Foo); + } + + // Test space to underscore conversion ("foo bar" -> "foo_bar") + uint32_t hash_space = fnv1_hash_object_id("foo bar", 7); + if (hash_space == 0x3ae35aa1) { + ESP_LOGI("FNV1_OID", "space PASSED"); + } else { + ESP_LOGE("FNV1_OID", "space FAILED: 0x%08x != 0x3ae35aa1", hash_space); + } + + // Test underscore preserved ("foo_bar") + uint32_t hash_underscore = fnv1_hash_object_id("foo_bar", 7); + if (hash_underscore == 0x3ae35aa1) { + ESP_LOGI("FNV1_OID", "underscore PASSED"); + } else { + ESP_LOGE("FNV1_OID", "underscore FAILED: 0x%08x != 0x3ae35aa1", hash_underscore); + } + + // Test hyphen preserved ("foo-bar") + uint32_t hash_hyphen = fnv1_hash_object_id("foo-bar", 7); + if (hash_hyphen == 0x438b12e3) { + ESP_LOGI("FNV1_OID", "hyphen PASSED"); + } else { + ESP_LOGE("FNV1_OID", "hyphen FAILED: 0x%08x != 0x438b12e3", hash_hyphen); + } + + // Test special chars become underscore ("foo!bar" -> "foo_bar") + uint32_t hash_special = fnv1_hash_object_id("foo!bar", 7); + if (hash_special == 0x3ae35aa1) { + ESP_LOGI("FNV1_OID", "special PASSED"); + } else { + ESP_LOGE("FNV1_OID", "special FAILED: 0x%08x != 0x3ae35aa1", hash_special); + } + + // Test complex name ("My Sensor Name" -> "my_sensor_name") + uint32_t hash_complex = fnv1_hash_object_id("My Sensor Name", 14); + if (hash_complex == 0x2760962a) { + ESP_LOGI("FNV1_OID", "complex PASSED"); + } else { + ESP_LOGE("FNV1_OID", "complex FAILED: 0x%08x != 0x2760962a", hash_complex); + } + + // Test empty string returns FNV1_OFFSET_BASIS + uint32_t hash_empty = fnv1_hash_object_id("", 0); + if (hash_empty == 0x811c9dc5) { + ESP_LOGI("FNV1_OID", "empty PASSED"); + } else { + ESP_LOGE("FNV1_OID", "empty FAILED: 0x%08x != 0x811c9dc5", hash_empty); + } + +host: +api: +logger: diff --git a/tests/integration/test_fnv1_hash_object_id.py b/tests/integration/test_fnv1_hash_object_id.py new file mode 100644 index 0000000000..23e8ca04c2 --- /dev/null +++ b/tests/integration/test_fnv1_hash_object_id.py @@ -0,0 +1,75 @@ +"""Integration test for fnv1_hash_object_id function. + +This test verifies that the C++ fnv1_hash_object_id() function in +esphome/core/helpers.h produces the same hash values as the Python +fnv1_hash_object_id() function in esphome/helpers.py. + +If this test fails, one of the implementations has diverged and needs +to be updated to match the other. +""" + +from __future__ import annotations + +import asyncio +import re + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_fnv1_hash_object_id( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test that C++ fnv1_hash_object_id matches Python implementation.""" + + test_results: dict[str, str] = {} + all_tests_complete = asyncio.Event() + expected_tests = { + "foo", + "upper", + "space", + "underscore", + "hyphen", + "special", + "complex", + "empty", + } + + def on_log_line(line: str) -> None: + """Capture log lines with test results.""" + # Strip ANSI escape codes + clean_line = re.sub(r"\x1b\[[0-9;]*m", "", line) + # Look for our test result messages + # Format: "[timestamp][level][FNV1_OID:line]: test_name PASSED" + match = re.search(r"\[FNV1_OID:\d+\]:\s+(\w+)\s+(PASSED|FAILED)", clean_line) + if match: + test_name = match.group(1) + result = match.group(2) + test_results[test_name] = result + if set(test_results.keys()) >= expected_tests: + all_tests_complete.set() + + async with ( + run_compiled(yaml_config, line_callback=on_log_line), + api_client_connected() as client, + ): + device_info = await client.device_info() + assert device_info is not None + assert device_info.name == "fnv1-hash-object-id-test" + + # Wait for all tests to complete or timeout + try: + await asyncio.wait_for(all_tests_complete.wait(), timeout=2.0) + except TimeoutError: + pytest.fail(f"Tests timed out. Got results for: {set(test_results.keys())}") + + # Verify all tests passed + for test_name in expected_tests: + assert test_name in test_results, f"{test_name} test not found" + assert test_results[test_name] == "PASSED", ( + f"{test_name} test failed - C++ and Python hash mismatch" + ) diff --git a/tests/unit_tests/test_helpers.py b/tests/unit_tests/test_helpers.py index 47b945e0eb..3b5ed8a424 100644 --- a/tests/unit_tests/test_helpers.py +++ b/tests/unit_tests/test_helpers.py @@ -279,6 +279,77 @@ def test_sanitize(text, expected): assert actual == expected +@pytest.mark.parametrize( + ("name", "expected_hash"), + ( + # Basic strings - hash of snake_case(sanitize(name)) + ("foo", 0x408F5E13), + ("Foo", 0x408F5E13), # Same as "foo" (lowercase) + ("FOO", 0x408F5E13), # Same as "foo" (lowercase) + # Spaces become underscores + ("foo bar", 0x3AE35AA1), # Transforms to "foo_bar" + ("Foo Bar", 0x3AE35AA1), # Same (lowercase + underscore) + # Already snake_case + ("foo_bar", 0x3AE35AA1), + # Special chars become underscores + ("foo!bar", 0x3AE35AA1), # Transforms to "foo_bar" + ("foo@bar", 0x3AE35AA1), # Transforms to "foo_bar" + # Hyphens are preserved + ("foo-bar", 0x438B12E3), + # Numbers are preserved + ("foo123", 0xF3B0067D), + # Empty string + ("", 0x811C9DC5), # FNV1_OFFSET_BASIS (no chars processed) + # Single char + ("a", 0x050C5D7E), + # Mixed case and spaces + ("My Sensor Name", 0x2760962A), # Transforms to "my_sensor_name" + ), +) +def test_fnv1_hash_object_id(name, expected_hash): + """Test fnv1_hash_object_id produces expected hashes. + + These expected values were computed to match the C++ implementation + in esphome/core/helpers.h. If this test fails after modifying either + implementation, ensure both Python and C++ versions stay in sync. + """ + actual = helpers.fnv1_hash_object_id(name) + + assert actual == expected_hash + + +def _fnv1_hash_py(s: str) -> int: + """Python implementation of FNV-1 hash for verification.""" + hash_val = 2166136261 # FNV1_OFFSET_BASIS + for c in s: + hash_val = (hash_val * 16777619) & 0xFFFFFFFF # FNV1_PRIME + hash_val ^= ord(c) + return hash_val + + +@pytest.mark.parametrize( + "name", + ( + "Simple", + "With Space", + "MixedCase", + "special!@#chars", + "already_snake_case", + "123numbers", + ), +) +def test_fnv1_hash_object_id_matches_manual_calculation(name): + """Verify fnv1_hash_object_id matches snake_case + sanitize + standard FNV-1.""" + # Manual calculation: snake_case -> sanitize -> fnv1_hash + transformed = helpers.sanitize(helpers.snake_case(name)) + expected = _fnv1_hash_py(transformed) + + # Direct calculation via fnv1_hash_object_id + actual = helpers.fnv1_hash_object_id(name) + + assert actual == expected + + @pytest.mark.parametrize( "text, expected", ((["127.0.0.1", "fe80::1", "2001::2"], ["2001::2", "127.0.0.1", "fe80::1"]),), From b6b871cb734dc7ad089ad852266c784700c47e45 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 22 Dec 2025 20:07:02 -1000 Subject: [PATCH 03/25] preen --- esphome/helpers.py | 42 ++++++++++++++++++------------------------ 1 file changed, 18 insertions(+), 24 deletions(-) diff --git a/esphome/helpers.py b/esphome/helpers.py index 18f459d3ee..ae142b7f8b 100644 --- a/esphome/helpers.py +++ b/esphome/helpers.py @@ -35,6 +35,10 @@ IS_MACOS = platform.system() == "Darwin" IS_WINDOWS = platform.system() == "Windows" IS_LINUX = platform.system() == "Linux" +# FNV-1 hash constants (must match C++ in esphome/core/helpers.h) +FNV1_OFFSET_BASIS = 2166136261 +FNV1_PRIME = 16777619 + def ensure_unique_string(preferred_string, current_strings): test_string = preferred_string @@ -49,8 +53,17 @@ def ensure_unique_string(preferred_string, current_strings): return test_string +def fnv1_hash(string: str) -> int: + """FNV-1 32-bit hash function (multiply then XOR).""" + hash_value = FNV1_OFFSET_BASIS + for char in string: + hash_value = (hash_value * FNV1_PRIME) & 0xFFFFFFFF + hash_value ^= ord(char) + return hash_value + + def fnv1a_32bit_hash(string: str) -> int: - """FNV-1a 32-bit hash function. + """FNV-1a 32-bit hash function (XOR then multiply). Note: This uses 32-bit hash instead of 64-bit for several reasons: 1. ESPHome targets 32-bit microcontrollers with limited RAM (often <320KB) @@ -63,39 +76,20 @@ def fnv1a_32bit_hash(string: str) -> int: a handful of area_ids and device_ids (typically <10 areas and <100 devices), making collisions virtually impossible. """ - hash_value = 2166136261 + hash_value = FNV1_OFFSET_BASIS for char in string: hash_value ^= ord(char) - hash_value = (hash_value * 16777619) & 0xFFFFFFFF + hash_value = (hash_value * FNV1_PRIME) & 0xFFFFFFFF return hash_value def fnv1_hash_object_id(name: str) -> int: """Compute FNV-1 hash of name with snake_case + sanitize transformations. - IMPORTANT: This must match the C++ fnv1_hash_object_id() in esphome/core/helpers.h. - If you modify this function, update the C++ version and tests in both places. - + IMPORTANT: Must produce same result as C++ fnv1_hash_object_id() in helpers.h. Used for pre-computing entity object_id hashes at code generation time. """ - hash_value = 2166136261 # FNV1_OFFSET_BASIS - for char in name: - # Apply snake_case: space -> underscore, uppercase -> lowercase - if char == " ": - c = "_" - elif "A" <= char <= "Z": - c = chr(ord(char) + 32) # lowercase - else: - c = char - # Apply sanitize: keep alphanumerics, dash, underscore; replace others with _ - if not ( - c in {"-", "_"} or "0" <= c <= "9" or "a" <= c <= "z" or "A" <= c <= "Z" - ): - c = "_" - # FNV-1: multiply then XOR - hash_value = (hash_value * 16777619) & 0xFFFFFFFF - hash_value ^= ord(c) - return hash_value + return fnv1_hash(sanitize(snake_case(name))) def strip_accents(value: str) -> str: From 9f2d2eed8cd793107f2ebbddfe5782f42638c37d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 22 Dec 2025 20:08:38 -1000 Subject: [PATCH 04/25] preen --- .../binary_sensor/test_binary_sensor.py | 2 +- tests/component_tests/button/test_button.py | 2 +- tests/component_tests/text/test_text.py | 2 +- .../text_sensor/test_text_sensor.py | 15 +++-------- tests/unit_tests/core/test_entity_helpers.py | 26 +++++++++---------- 5 files changed, 18 insertions(+), 29 deletions(-) diff --git a/tests/component_tests/binary_sensor/test_binary_sensor.py b/tests/component_tests/binary_sensor/test_binary_sensor.py index 86e0705023..ce4e64681f 100644 --- a/tests/component_tests/binary_sensor/test_binary_sensor.py +++ b/tests/component_tests/binary_sensor/test_binary_sensor.py @@ -29,7 +29,7 @@ def test_binary_sensor_sets_mandatory_fields(generate_main): ) # Then - assert 'bs_1->set_name_and_object_id("test bs1", "test_bs1");' in main_cpp + assert 'bs_1->set_name("test bs1",' in main_cpp assert "bs_1->set_pin(" in main_cpp diff --git a/tests/component_tests/button/test_button.py b/tests/component_tests/button/test_button.py index b21665288c..797b6fb1a4 100644 --- a/tests/component_tests/button/test_button.py +++ b/tests/component_tests/button/test_button.py @@ -26,7 +26,7 @@ def test_button_sets_mandatory_fields(generate_main): main_cpp = generate_main("tests/component_tests/button/test_button.yaml") # Then - assert 'wol_1->set_name_and_object_id("wol_test_1", "wol_test_1");' in main_cpp + assert 'wol_1->set_name("wol_test_1",' in main_cpp assert "wol_2->set_macaddr(18, 52, 86, 120, 144, 171);" in main_cpp diff --git a/tests/component_tests/text/test_text.py b/tests/component_tests/text/test_text.py index bfc3131f6d..6b047bc62f 100644 --- a/tests/component_tests/text/test_text.py +++ b/tests/component_tests/text/test_text.py @@ -25,7 +25,7 @@ def test_text_sets_mandatory_fields(generate_main): main_cpp = generate_main("tests/component_tests/text/test_text.yaml") # Then - assert 'it_1->set_name_and_object_id("test 1 text", "test_1_text");' in main_cpp + assert 'it_1->set_name("test 1 text",' in main_cpp def test_text_config_value_internal_set(generate_main): diff --git a/tests/component_tests/text_sensor/test_text_sensor.py b/tests/component_tests/text_sensor/test_text_sensor.py index 934ee67cef..1593d0b6d8 100644 --- a/tests/component_tests/text_sensor/test_text_sensor.py +++ b/tests/component_tests/text_sensor/test_text_sensor.py @@ -25,18 +25,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 ( - 'ts_1->set_name_and_object_id("Template Text Sensor 1", "template_text_sensor_1");' - in main_cpp - ) - assert ( - 'ts_2->set_name_and_object_id("Template Text Sensor 2", "template_text_sensor_2");' - in main_cpp - ) - assert ( - 'ts_3->set_name_and_object_id("Template Text Sensor 3", "template_text_sensor_3");' - in main_cpp - ) + assert 'ts_1->set_name("Template Text Sensor 1",' in main_cpp + assert 'ts_2->set_name("Template Text Sensor 2",' in main_cpp + assert 'ts_3->set_name("Template Text Sensor 3",' 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 01de0f27f9..9a16e751a6 100644 --- a/tests/unit_tests/core/test_entity_helpers.py +++ b/tests/unit_tests/core/test_entity_helpers.py @@ -27,13 +27,9 @@ from esphome.helpers import sanitize, snake_case from .common import load_config_from_fixture -# Pre-compiled regex patterns for extracting object IDs from expressions -# Matches both old format: .set_object_id("obj_id") -# and new format: .set_name_and_object_id("name", "obj_id") -OBJECT_ID_PATTERN = re.compile(r'\.set_object_id\(["\'](.*?)["\']\)') -COMBINED_PATTERN = re.compile( - r'\.set_name_and_object_id\(["\'].*?["\']\s*,\s*["\'](.*?)["\']\)' -) +# Pre-compiled regex pattern for extracting names from set_name calls +# Matches: .set_name("name", hash) or .set_name("name") +SET_NAME_PATTERN = re.compile(r'\.set_name\(["\']([^"\']*)["\']') FIXTURES_DIR = Path(__file__).parent.parent / "fixtures" / "core" / "entity_helpers" @@ -276,14 +272,16 @@ def setup_test_environment() -> Generator[list[str], None, None]: def extract_object_id_from_expressions(expressions: list[str]) -> str | None: - """Extract the object ID that was set from the generated expressions.""" + """Extract the object ID that would be computed from set_name calls. + + Since object_id is now computed from the name (via snake_case + sanitize), + we extract the name from set_name() calls and compute the expected object_id. + """ for expr in expressions: - # First try new combined format: .set_name_and_object_id("name", "obj_id") - if match := COMBINED_PATTERN.search(expr): - return match.group(1) - # Fall back to old format: .set_object_id("obj_id") - if match := OBJECT_ID_PATTERN.search(expr): - return match.group(1) + if match := SET_NAME_PATTERN.search(expr): + name = match.group(1) + # Compute object_id the same way as get_base_entity_object_id + return sanitize(snake_case(name)) if name else None return None From e13f48b3481ad211db4e8f4867d48d4b93ca5b02 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 22 Dec 2025 20:10:36 -1000 Subject: [PATCH 05/25] preen --- tests/unit_tests/core/test_entity_helpers.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/unit_tests/core/test_entity_helpers.py b/tests/unit_tests/core/test_entity_helpers.py index 9a16e751a6..0bc86eec8d 100644 --- a/tests/unit_tests/core/test_entity_helpers.py +++ b/tests/unit_tests/core/test_entity_helpers.py @@ -276,12 +276,17 @@ def extract_object_id_from_expressions(expressions: list[str]) -> str | None: Since object_id is now computed from the name (via snake_case + sanitize), we extract the name from set_name() calls and compute the expected object_id. + For empty names, we fall back to CORE.friendly_name or CORE.name. """ for expr in expressions: if match := SET_NAME_PATTERN.search(expr): name = match.group(1) - # Compute object_id the same way as get_base_entity_object_id - return sanitize(snake_case(name)) if name else None + if name: + return sanitize(snake_case(name)) + # Empty name - fall back to friendly_name or device name + if CORE.friendly_name: + return sanitize(snake_case(CORE.friendly_name)) + return sanitize(snake_case(CORE.name)) if CORE.name else None return None From 3e1db740eac5d2b81bb7c7cf9938eeca97264d93 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 22 Dec 2025 21:40:10 -1000 Subject: [PATCH 06/25] cover --- .../fixtures/object_id_api_verification.yaml | 95 ++++++++++ .../test_object_id_api_verification.py | 168 ++++++++++++++++++ 2 files changed, 263 insertions(+) create mode 100644 tests/integration/fixtures/object_id_api_verification.yaml create mode 100644 tests/integration/test_object_id_api_verification.py diff --git a/tests/integration/fixtures/object_id_api_verification.yaml b/tests/integration/fixtures/object_id_api_verification.yaml new file mode 100644 index 0000000000..0a8deff4da --- /dev/null +++ b/tests/integration/fixtures/object_id_api_verification.yaml @@ -0,0 +1,95 @@ +esphome: + name: object-id-test + friendly_name: Test Device + # Enable MAC suffix - host MAC is 98:35:69:ab:f6:79, suffix is "abf679" + # friendly_name becomes "Test Device abf679" + name_add_mac_suffix: true + +host: + +api: + +logger: + +sensor: + # Test 1: Basic name -> object_id = "temperature_sensor" + - platform: template + name: "Temperature Sensor" + id: sensor_basic + lambda: return 42.0; + update_interval: 60s + + # Test 2: Uppercase name -> object_id = "uppercase_name" + - platform: template + name: "UPPERCASE NAME" + id: sensor_uppercase + lambda: return 43.0; + update_interval: 60s + + # Test 3: Special characters -> object_id = "special__chars_" + - platform: template + name: "Special!@Chars#" + id: sensor_special + lambda: return 44.0; + update_interval: 60s + + # Test 4: Hyphen preserved -> object_id = "temp-sensor" + - platform: template + name: "Temp-Sensor" + id: sensor_hyphen + lambda: return 45.0; + update_interval: 60s + + # Test 5: Underscore preserved -> object_id = "temp_sensor" + - platform: template + name: "Temp_Sensor" + id: sensor_underscore + lambda: return 46.0; + update_interval: 60s + + # Test 6: Mixed case with spaces -> object_id = "living_room_temperature" + - platform: template + name: "Living Room Temperature" + id: sensor_mixed + lambda: return 47.0; + update_interval: 60s + + # Test 7: Empty name - uses friendly_name with MAC suffix + # friendly_name = "Test Device abf679" -> object_id = "test_device_abf679" + - platform: template + name: "" + id: sensor_empty_name + lambda: return 48.0; + update_interval: 60s + +binary_sensor: + # Test 8: Different platform same conversion rules + - platform: template + name: "Door Open" + id: binary_door + lambda: return true; + + # Test 9: Numbers in name -> object_id = "sensor_123" + - platform: template + name: "Sensor 123" + id: binary_numbers + lambda: return false; + +switch: + # Test 10: Long name with multiple spaces + - platform: template + name: "My Very Long Switch Name Here" + id: switch_long + lambda: return false; + turn_on_action: + - logger.log: "on" + turn_off_action: + - logger.log: "off" + +text_sensor: + # Test 11: Name starting with number (should work fine) + - platform: template + name: "123 Start" + id: text_num_start + lambda: return {"test"}; + update_interval: 60s diff --git a/tests/integration/test_object_id_api_verification.py b/tests/integration/test_object_id_api_verification.py new file mode 100644 index 0000000000..e90f6c273d --- /dev/null +++ b/tests/integration/test_object_id_api_verification.py @@ -0,0 +1,168 @@ +"""Integration test to verify object_id from API matches Python computation. + +This test verifies a three-way match between: +1. C++ object_id generation (get_object_id_to using to_sanitized_char/to_snake_case_char) +2. C++ hash generation (fnv1_hash_object_id in helpers.h) +3. Python computation (sanitize/snake_case in helpers.py, fnv1_hash_object_id) + +The API response contains C++ computed values, so verifying API == Python +implicitly verifies C++ == Python == API for both object_id and hash. + +This is important for the planned migration to remove object_id from the API +protocol and have clients (like aioesphomeapi) compute it from the name. +See: https://github.com/esphome/backlog/issues/76 + +Test cases covered: +- Named entities with various characters (uppercase, special chars, hyphens, etc.) +- Empty-name entities (has_own_name=false, uses device's friendly_name) +- MAC suffix handling (name_add_mac_suffix modifies friendly_name at runtime) +- Both object_id string and hash (key) verification +""" + +from __future__ import annotations + +import pytest + +from esphome.helpers import fnv1_hash_object_id, sanitize, snake_case + +from .types import APIClientConnectedFactory, RunCompiledFunction + +# Host platform default MAC: 98:35:69:ab:f6:79 -> suffix "abf679" +MAC_SUFFIX = "abf679" + + +# Expected entities with their own names and expected object_ids +# Format: (entity_name, expected_object_id) +NAMED_ENTITIES = [ + # sensor platform + ("Temperature Sensor", "temperature_sensor"), + ("UPPERCASE NAME", "uppercase_name"), + ("Special!@Chars#", "special__chars_"), + ("Temp-Sensor", "temp-sensor"), + ("Temp_Sensor", "temp_sensor"), + ("Living Room Temperature", "living_room_temperature"), + # binary_sensor platform + ("Door Open", "door_open"), + ("Sensor 123", "sensor_123"), + # switch platform + ("My Very Long Switch Name Here", "my_very_long_switch_name_here"), + # text_sensor platform + ("123 Start", "123_start"), +] + + +def compute_expected_object_id(name: str) -> str: + """Compute expected object_id from name using Python helpers.""" + return sanitize(snake_case(name)) + + +@pytest.mark.asyncio +async def test_object_id_api_verification( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test that object_id from API matches Python computation. + + Tests: + 1. Named entities - object_id computed from entity name + 2. Empty-name entities - object_id computed from friendly_name (with MAC suffix) + 3. Hash verification - key can be computed from name + 4. Generic verification - all entities can have object_id computed from API data + """ + async with run_compiled(yaml_config), api_client_connected() as client: + # Get device info + device_info = await client.device_info() + assert device_info is not None + + # Device name should include MAC suffix (hyphen separator) + assert device_info.name == f"object-id-test-{MAC_SUFFIX}", ( + f"Device name mismatch: got '{device_info.name}'" + ) + # Friendly name should include MAC suffix (space separator) + expected_friendly_name = f"Test Device {MAC_SUFFIX}" + assert device_info.friendly_name == expected_friendly_name, ( + f"Friendly name mismatch: got '{device_info.friendly_name}'" + ) + + # Get all entities + entities, _ = await client.list_entities_services() + + # Create a map of entity names to entity info + entity_map = {} + for entity in entities: + entity_map[entity.name] = entity + + # === Test 1: Verify each named entity === + for entity_name, expected_object_id in NAMED_ENTITIES: + assert entity_name in entity_map, ( + f"Entity '{entity_name}' not found in API response. " + f"Available: {list(entity_map.keys())}" + ) + + entity = entity_map[entity_name] + + # Verify object_id matches expected + assert entity.object_id == expected_object_id, ( + f"Entity '{entity_name}': object_id mismatch. " + f"API returned '{entity.object_id}', expected '{expected_object_id}'" + ) + + # Verify Python computation matches + computed = compute_expected_object_id(entity_name) + assert computed == expected_object_id, ( + f"Entity '{entity_name}': Python computation mismatch. " + f"Computed '{computed}', expected '{expected_object_id}'" + ) + + # Verify hash can be computed from the name + hash_from_name = fnv1_hash_object_id(entity_name) + assert hash_from_name == entity.key, ( + f"Entity '{entity_name}': hash mismatch. " + f"Python hash {hash_from_name:#x}, API key {entity.key:#x}" + ) + + # === Test 2: Verify empty-name entity (has_own_name=false) === + # When entity has no name, the name field is empty in the API message + # and the entity uses device's friendly_name (with MAC suffix) for display + assert "" in entity_map, ( + "Empty-name entity not found. " + f"Available entity names: {list(entity_map.keys())}" + ) + empty_name_entity = entity_map[""] + + # object_id is computed from friendly_name (which includes MAC suffix) + expected_object_id_empty = compute_expected_object_id(expected_friendly_name) + assert empty_name_entity.object_id == expected_object_id_empty, ( + f"Empty-name entity: object_id mismatch. " + f"API: '{empty_name_entity.object_id}', expected: '{expected_object_id_empty}'" + ) + + # Hash is also computed from friendly_name with MAC suffix + expected_hash_empty = fnv1_hash_object_id(expected_friendly_name) + assert empty_name_entity.key == expected_hash_empty, ( + f"Empty-name entity: hash mismatch. " + f"API key: {empty_name_entity.key:#x}, expected: {expected_hash_empty:#x}" + ) + + # === Test 3: Verify ALL entities can have object_id computed from API data === + # This is the key property for removing object_id from the API protocol + for entity in entities: + # Use entity name if present, otherwise device's friendly_name + name_for_object_id = entity.name or device_info.friendly_name + + # Compute object_id from the appropriate name + computed_object_id = compute_expected_object_id(name_for_object_id) + + # Verify it matches what the API returned + assert entity.object_id == computed_object_id, ( + f"Entity (name='{entity.name}'): object_id cannot be computed. " + f"API: '{entity.object_id}', Computed from '{name_for_object_id}': '{computed_object_id}'" + ) + + # Verify hash can also be computed + computed_hash = fnv1_hash_object_id(name_for_object_id) + assert entity.key == computed_hash, ( + f"Entity (name='{entity.name}'): hash cannot be computed. " + f"API key: {entity.key:#x}, Computed: {computed_hash:#x}" + ) From 6d5ab003851c9c7267c1b15874015bcd46fa3d17 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 22 Dec 2025 21:42:50 -1000 Subject: [PATCH 07/25] tweak --- esphome/core/entity_base.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/esphome/core/entity_base.cpp b/esphome/core/entity_base.cpp index f5d563dead..cde3c6bf39 100644 --- a/esphome/core/entity_base.cpp +++ b/esphome/core/entity_base.cpp @@ -19,7 +19,9 @@ void EntityBase::set_name(const char *name, uint32_t object_id_hash) { } else #endif { - this->name_ = StringRef(App.get_friendly_name()); + // Use friendly_name if available, otherwise fall back to device name + const std::string &friendly = App.get_friendly_name(); + this->name_ = StringRef(!friendly.empty() ? friendly : App.get_name()); } this->flags_.has_own_name = false; // Dynamic name - must calculate hash at runtime From 4bec2dc75c349b6fa3230327b1313ced891e24df Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 22 Dec 2025 21:51:57 -1000 Subject: [PATCH 08/25] tweak --- esphome/core/entity_base.cpp | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/esphome/core/entity_base.cpp b/esphome/core/entity_base.cpp index cde3c6bf39..4b0547a8b4 100644 --- a/esphome/core/entity_base.cpp +++ b/esphome/core/entity_base.cpp @@ -78,11 +78,7 @@ size_t EntityBase::write_object_id_to(char *buf, size_t buf_size) const { } StringRef EntityBase::get_object_id_to(std::span buf) const { - size_t len = std::min(this->name_.size(), buf.size() - 1); - for (size_t i = 0; i < len; i++) { - buf[i] = to_sanitized_char(to_snake_case_char(this->name_[i])); - } - buf[len] = '\0'; + size_t len = this->write_object_id_to(buf.data(), buf.size()); return StringRef(buf.data(), len); } From da8e23f968840c360bca01bbcec8598463023749 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 22 Dec 2025 21:58:58 -1000 Subject: [PATCH 09/25] more cover --- .../fixtures/object_id_api_verification.yaml | 30 +++++++ .../test_object_id_api_verification.py | 86 +++++++++++++------ 2 files changed, 92 insertions(+), 24 deletions(-) diff --git a/tests/integration/fixtures/object_id_api_verification.yaml b/tests/integration/fixtures/object_id_api_verification.yaml index 0a8deff4da..386270fc2c 100644 --- a/tests/integration/fixtures/object_id_api_verification.yaml +++ b/tests/integration/fixtures/object_id_api_verification.yaml @@ -4,6 +4,12 @@ esphome: # Enable MAC suffix - host MAC is 98:35:69:ab:f6:79, suffix is "abf679" # friendly_name becomes "Test Device abf679" name_add_mac_suffix: true + # Sub-devices for testing empty-name entities on devices + devices: + - id: sub_device_1 + name: Sub Device One + - id: sub_device_2 + name: Sub Device Two host: @@ -93,3 +99,27 @@ text_sensor: id: text_num_start lambda: return {"test"}; update_interval: 60s + +button: + # Test 12: Named entity on sub-device -> object_id from entity name + - platform: template + name: "Device Button" + id: button_on_device + device_id: sub_device_1 + on_press: [] + + # Test 13: Empty name on sub-device -> object_id from device name + # Device name "Sub Device One" -> object_id = "sub_device_one" + - platform: template + name: "" + id: button_empty_on_device1 + device_id: sub_device_1 + on_press: [] + + # Test 14: Empty name on different sub-device + # Device name "Sub Device Two" -> object_id = "sub_device_two" + - platform: template + name: "" + id: button_empty_on_device2 + device_id: sub_device_2 + on_press: [] diff --git a/tests/integration/test_object_id_api_verification.py b/tests/integration/test_object_id_api_verification.py index e90f6c273d..c19c3a22df 100644 --- a/tests/integration/test_object_id_api_verification.py +++ b/tests/integration/test_object_id_api_verification.py @@ -14,7 +14,9 @@ See: https://github.com/esphome/backlog/issues/76 Test cases covered: - Named entities with various characters (uppercase, special chars, hyphens, etc.) -- Empty-name entities (has_own_name=false, uses device's friendly_name) +- Empty-name entities on main device (uses device's friendly_name with MAC suffix) +- Empty-name entities on sub-devices (uses sub-device's name) +- Named entities on sub-devices (uses entity name, not device name) - MAC suffix handling (name_add_mac_suffix modifies friendly_name at runtime) - Both object_id string and hash (key) verification """ @@ -48,6 +50,15 @@ NAMED_ENTITIES = [ ("My Very Long Switch Name Here", "my_very_long_switch_name_here"), # text_sensor platform ("123 Start", "123_start"), + # button platform - named entity on sub-device (uses entity name, not device name) + ("Device Button", "device_button"), +] + +# Sub-device names and their expected object_ids for empty-name entities +# Format: (device_name, expected_object_id) +SUB_DEVICE_EMPTY_NAME_ENTITIES = [ + ("Sub Device One", "sub_device_one"), + ("Sub Device Two", "sub_device_two"), ] @@ -122,47 +133,74 @@ async def test_object_id_api_verification( f"Python hash {hash_from_name:#x}, API key {entity.key:#x}" ) - # === Test 2: Verify empty-name entity (has_own_name=false) === - # When entity has no name, the name field is empty in the API message - # and the entity uses device's friendly_name (with MAC suffix) for display - assert "" in entity_map, ( - "Empty-name entity not found. " - f"Available entity names: {list(entity_map.keys())}" - ) - empty_name_entity = entity_map[""] + # === Test 2: Verify empty-name entities === + # Empty-name entities have name="" in API, object_id comes from: + # - Main device: friendly_name (with MAC suffix) + # - Sub-device: device name - # object_id is computed from friendly_name (which includes MAC suffix) - expected_object_id_empty = compute_expected_object_id(expected_friendly_name) - assert empty_name_entity.object_id == expected_object_id_empty, ( - f"Empty-name entity: object_id mismatch. " - f"API: '{empty_name_entity.object_id}', expected: '{expected_object_id_empty}'" + # Get all empty-name entities + empty_name_entities = [e for e in entities if e.name == ""] + # We expect 3: 1 on main device, 2 on sub-devices + assert len(empty_name_entities) == 3, ( + f"Expected 3 empty-name entities, got {len(empty_name_entities)}" ) - # Hash is also computed from friendly_name with MAC suffix - expected_hash_empty = fnv1_hash_object_id(expected_friendly_name) - assert empty_name_entity.key == expected_hash_empty, ( - f"Empty-name entity: hash mismatch. " - f"API key: {empty_name_entity.key:#x}, expected: {expected_hash_empty:#x}" - ) + # Build device_id -> device_name map from device_info + device_id_to_name = {d.device_id: d.name for d in device_info.devices} + + # Verify each empty-name entity + for entity in empty_name_entities: + if entity.device_id == 0: + # Main device - uses friendly_name with MAC suffix + expected_name = expected_friendly_name + else: + # Sub-device - uses device name + assert entity.device_id in device_id_to_name, ( + f"Entity device_id {entity.device_id} not found in devices" + ) + expected_name = device_id_to_name[entity.device_id] + + expected_object_id = compute_expected_object_id(expected_name) + assert entity.object_id == expected_object_id, ( + f"Empty-name entity (device_id={entity.device_id}): object_id mismatch. " + f"API: '{entity.object_id}', expected: '{expected_object_id}' " + f"(from name '{expected_name}')" + ) + + # Verify hash matches + expected_hash = fnv1_hash_object_id(expected_name) + assert entity.key == expected_hash, ( + f"Empty-name entity (device_id={entity.device_id}): hash mismatch. " + f"API key: {entity.key:#x}, expected: {expected_hash:#x}" + ) # === Test 3: Verify ALL entities can have object_id computed from API data === # This is the key property for removing object_id from the API protocol for entity in entities: - # Use entity name if present, otherwise device's friendly_name - name_for_object_id = entity.name or device_info.friendly_name + if entity.name: + # Named entity - use entity name + name_for_object_id = entity.name + elif entity.device_id == 0: + # Empty name on main device - use friendly_name + name_for_object_id = device_info.friendly_name + else: + # Empty name on sub-device - use device name + name_for_object_id = device_id_to_name[entity.device_id] # Compute object_id from the appropriate name computed_object_id = compute_expected_object_id(name_for_object_id) # Verify it matches what the API returned assert entity.object_id == computed_object_id, ( - f"Entity (name='{entity.name}'): object_id cannot be computed. " + f"Entity (name='{entity.name}', device_id={entity.device_id}): " + f"object_id cannot be computed. " f"API: '{entity.object_id}', Computed from '{name_for_object_id}': '{computed_object_id}'" ) # Verify hash can also be computed computed_hash = fnv1_hash_object_id(name_for_object_id) assert entity.key == computed_hash, ( - f"Entity (name='{entity.name}'): hash cannot be computed. " + f"Entity (name='{entity.name}', device_id={entity.device_id}): " + f"hash cannot be computed. " f"API key: {entity.key:#x}, Computed: {computed_hash:#x}" ) From 2d6b9b3888b311d59c60e51a164a20fdee60a695 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 22 Dec 2025 22:06:48 -1000 Subject: [PATCH 10/25] more cover --- tests/unit_tests/test_helpers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit_tests/test_helpers.py b/tests/unit_tests/test_helpers.py index 3b5ed8a424..159d3230ab 100644 --- a/tests/unit_tests/test_helpers.py +++ b/tests/unit_tests/test_helpers.py @@ -282,7 +282,7 @@ def test_sanitize(text, expected): @pytest.mark.parametrize( ("name", "expected_hash"), ( - # Basic strings - hash of snake_case(sanitize(name)) + # Basic strings - hash of sanitize(snake_case(name)) ("foo", 0x408F5E13), ("Foo", 0x408F5E13), # Same as "foo" (lowercase) ("FOO", 0x408F5E13), # Same as "foo" (lowercase) From f9a4a8a82ea454dcc27c00dd6b23472f6af6d1c4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 22 Dec 2025 23:11:12 -1000 Subject: [PATCH 11/25] tweaks --- esphome/components/pid/pid_climate.cpp | 2 +- esphome/components/prometheus/prometheus_handler.cpp | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/pid/pid_climate.cpp b/esphome/components/pid/pid_climate.cpp index 25aae7c4cb..ba3b8ec98a 100644 --- a/esphome/components/pid/pid_climate.cpp +++ b/esphome/components/pid/pid_climate.cpp @@ -165,7 +165,7 @@ void PIDClimate::start_autotune(std::unique_ptr &&autotune) { this->autotuner_->config(min_value, max_value); char object_id_buf[OBJECT_ID_MAX_LEN]; StringRef object_id = this->get_object_id_to(object_id_buf); - this->autotuner_->set_autotuner_id(std::string(object_id.c_str())); + this->autotuner_->set_autotuner_id(object_id.str()); ESP_LOGI(TAG, "%s: Autotune has started. This can take a long time depending on the " diff --git a/esphome/components/prometheus/prometheus_handler.cpp b/esphome/components/prometheus/prometheus_handler.cpp index 95ddc87b7e..9ae4a74718 100644 --- a/esphome/components/prometheus/prometheus_handler.cpp +++ b/esphome/components/prometheus/prometheus_handler.cpp @@ -117,7 +117,7 @@ std::string PrometheusHandler::relabel_id_(EntityBase *obj) { } char object_id_buf[OBJECT_ID_MAX_LEN]; StringRef object_id = obj->get_object_id_to(object_id_buf); - return std::string(object_id.c_str()); + return object_id.str(); } std::string PrometheusHandler::relabel_name_(EntityBase *obj) { From 9205cb3d67c81b4a8951453d24295da1690f660b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 22 Dec 2025 23:11:12 -1000 Subject: [PATCH 12/25] tweaks --- esphome/components/pid/pid_climate.cpp | 2 +- esphome/components/prometheus/prometheus_handler.cpp | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/pid/pid_climate.cpp b/esphome/components/pid/pid_climate.cpp index 25aae7c4cb..ba3b8ec98a 100644 --- a/esphome/components/pid/pid_climate.cpp +++ b/esphome/components/pid/pid_climate.cpp @@ -165,7 +165,7 @@ void PIDClimate::start_autotune(std::unique_ptr &&autotune) { this->autotuner_->config(min_value, max_value); char object_id_buf[OBJECT_ID_MAX_LEN]; StringRef object_id = this->get_object_id_to(object_id_buf); - this->autotuner_->set_autotuner_id(std::string(object_id.c_str())); + this->autotuner_->set_autotuner_id(object_id.str()); ESP_LOGI(TAG, "%s: Autotune has started. This can take a long time depending on the " diff --git a/esphome/components/prometheus/prometheus_handler.cpp b/esphome/components/prometheus/prometheus_handler.cpp index 95ddc87b7e..9ae4a74718 100644 --- a/esphome/components/prometheus/prometheus_handler.cpp +++ b/esphome/components/prometheus/prometheus_handler.cpp @@ -117,7 +117,7 @@ std::string PrometheusHandler::relabel_id_(EntityBase *obj) { } char object_id_buf[OBJECT_ID_MAX_LEN]; StringRef object_id = obj->get_object_id_to(object_id_buf); - return std::string(object_id.c_str()); + return object_id.str(); } std::string PrometheusHandler::relabel_name_(EntityBase *obj) { From fa2bc21d3d013f10ecd40d3681cfc2c580575b50 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 22 Dec 2025 23:13:28 -1000 Subject: [PATCH 13/25] tweaks --- esphome/components/prometheus/prometheus_handler.cpp | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/esphome/components/prometheus/prometheus_handler.cpp b/esphome/components/prometheus/prometheus_handler.cpp index 9ae4a74718..88b357041a 100644 --- a/esphome/components/prometheus/prometheus_handler.cpp +++ b/esphome/components/prometheus/prometheus_handler.cpp @@ -116,8 +116,7 @@ std::string PrometheusHandler::relabel_id_(EntityBase *obj) { return item->second; } char object_id_buf[OBJECT_ID_MAX_LEN]; - StringRef object_id = obj->get_object_id_to(object_id_buf); - return object_id.str(); + return obj->get_object_id_to(object_id_buf).str(); } std::string PrometheusHandler::relabel_name_(EntityBase *obj) { From d334d0d4583f750a0e1a6cb11cc7fe66057ae860 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 22 Dec 2025 23:16:28 -1000 Subject: [PATCH 14/25] tweaks --- esphome/components/pid/pid_climate.cpp | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/esphome/components/pid/pid_climate.cpp b/esphome/components/pid/pid_climate.cpp index ba3b8ec98a..6ef01698be 100644 --- a/esphome/components/pid/pid_climate.cpp +++ b/esphome/components/pid/pid_climate.cpp @@ -1,5 +1,4 @@ #include "pid_climate.h" -#include "esphome/core/entity_base.h" #include "esphome/core/log.h" namespace esphome { @@ -163,16 +162,14 @@ void PIDClimate::start_autotune(std::unique_ptr &&autotune) { float min_value = this->supports_cool_() ? -1.0f : 0.0f; float max_value = this->supports_heat_() ? 1.0f : 0.0f; this->autotuner_->config(min_value, max_value); - char object_id_buf[OBJECT_ID_MAX_LEN]; - StringRef object_id = this->get_object_id_to(object_id_buf); - this->autotuner_->set_autotuner_id(object_id.str()); + this->autotuner_->set_autotuner_id(this->get_name().str()); ESP_LOGI(TAG, "%s: Autotune has started. This can take a long time depending on the " "responsiveness of your system. Your system " "output will be altered to deliberately oscillate above and below the setpoint multiple times. " "Until your sensor provides a reading, the autotuner may display \'nan\'", - object_id.c_str()); + this->get_name().c_str()); this->set_interval("autotune-progress", 10000, [this]() { if (this->autotuner_ != nullptr && !this->autotuner_->is_finished()) @@ -180,7 +177,8 @@ void PIDClimate::start_autotune(std::unique_ptr &&autotune) { }); if (mode != climate::CLIMATE_MODE_HEAT_COOL) { - ESP_LOGW(TAG, "%s: !!! For PID autotuner you need to set AUTO (also called heat/cool) mode!", object_id.c_str()); + ESP_LOGW(TAG, "%s: !!! For PID autotuner you need to set AUTO (also called heat/cool) mode!", + this->get_name().c_str()); } } From 3009da14f1af6954ffe1ea4c8be81f46f75b5463 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 22 Dec 2025 23:17:15 -1000 Subject: [PATCH 15/25] tweaks --- esphome/components/pid/pid_climate.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/pid/pid_climate.cpp b/esphome/components/pid/pid_climate.cpp index 6ef01698be..2094c0e942 100644 --- a/esphome/components/pid/pid_climate.cpp +++ b/esphome/components/pid/pid_climate.cpp @@ -162,7 +162,7 @@ void PIDClimate::start_autotune(std::unique_ptr &&autotune) { float min_value = this->supports_cool_() ? -1.0f : 0.0f; float max_value = this->supports_heat_() ? 1.0f : 0.0f; this->autotuner_->config(min_value, max_value); - this->autotuner_->set_autotuner_id(this->get_name().str()); + this->autotuner_->set_autotuner_id(this->get_name()); ESP_LOGI(TAG, "%s: Autotune has started. This can take a long time depending on the " From 3ef4e0bc473a4b56cbfbaabb46616434d84d2278 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 23 Dec 2025 00:00:03 -1000 Subject: [PATCH 16/25] fixes --- esphome/core/entity_helpers.py | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/esphome/core/entity_helpers.py b/esphome/core/entity_helpers.py index f5e57300c8..421386f30a 100644 --- a/esphome/core/entity_helpers.py +++ b/esphome/core/entity_helpers.py @@ -75,21 +75,31 @@ async def setup_entity(var: MockObj, config: ConfigType, platform: str) -> None: config: Configuration dictionary containing entity settings platform: The platform name (e.g., "sensor", "binary_sensor") """ - # Set device if configured + # Get device info if configured + device_name: str | None = None device_id_obj: ID | None if device_id_obj := config.get(CONF_DEVICE_ID): device: MockObj = await get_variable(device_id_obj) add(var.set_device(device)) + device_name = device_id_obj.id # Set the entity name with pre-computed object_id hash - # For entities with a name, we pre-compute the hash to avoid runtime calculation - # For empty names (use device friendly_name), pass 0 to compute at runtime + # We always pre-compute the hash using the same fallback logic as get_base_entity_object_id + # to ensure hash matches the object_id that would be generated entity_name = config[CONF_NAME] if entity_name: + # Named entity - hash from entity name object_id_hash = fnv1_hash_object_id(entity_name) - add(var.set_name(entity_name, object_id_hash)) else: - add(var.set_name(entity_name, 0)) + # Empty name - use fallback logic: device_name -> friendly_name -> CORE.name + if device_name: + base_name = device_name + elif CORE.friendly_name: + base_name = CORE.friendly_name + else: + base_name = CORE.name + object_id_hash = fnv1_hash_object_id(base_name) + add(var.set_name(entity_name, object_id_hash)) # Only set disabled_by_default if True (default is False) if config[CONF_DISABLED_BY_DEFAULT]: add(var.set_disabled_by_default(True)) From 1beec0ecf1587ccbb34f0f49617fd0f07514c3ca Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 23 Dec 2025 00:05:12 -1000 Subject: [PATCH 17/25] bug for bug compat --- esphome/core/entity_base.cpp | 12 +- esphome/core/entity_helpers.py | 14 +- ...ect_id_no_friendly_name_no_mac_suffix.yaml | 25 ++++ ...t_id_no_friendly_name_with_mac_suffix.yaml | 26 ++++ .../test_object_id_no_friendly_name.py | 138 ++++++++++++++++++ 5 files changed, 210 insertions(+), 5 deletions(-) create mode 100644 tests/integration/fixtures/object_id_no_friendly_name_no_mac_suffix.yaml create mode 100644 tests/integration/fixtures/object_id_no_friendly_name_with_mac_suffix.yaml create mode 100644 tests/integration/test_object_id_no_friendly_name.py diff --git a/esphome/core/entity_base.cpp b/esphome/core/entity_base.cpp index 4b0547a8b4..8508b93411 100644 --- a/esphome/core/entity_base.cpp +++ b/esphome/core/entity_base.cpp @@ -19,9 +19,17 @@ void EntityBase::set_name(const char *name, uint32_t object_id_hash) { } else #endif { - // Use friendly_name if available, otherwise fall back to device name + // Bug-for-bug compatibility with OLD behavior: + // - With MAC suffix: OLD code used App.get_friendly_name() directly (no fallback) + // - Without MAC suffix: OLD code used pre-computed object_id with fallback to device name const std::string &friendly = App.get_friendly_name(); - this->name_ = StringRef(!friendly.empty() ? friendly : App.get_name()); + if (App.is_name_add_mac_suffix_enabled()) { + // MAC suffix enabled - use friendly_name directly (even if empty) for compatibility + this->name_ = StringRef(friendly); + } else { + // No MAC suffix - fallback to device name if friendly_name is empty + this->name_ = StringRef(!friendly.empty() ? friendly : App.get_name()); + } } this->flags_.has_own_name = false; // Dynamic name - must calculate hash at runtime diff --git a/esphome/core/entity_helpers.py b/esphome/core/entity_helpers.py index 421386f30a..ac23ea6d34 100644 --- a/esphome/core/entity_helpers.py +++ b/esphome/core/entity_helpers.py @@ -84,19 +84,27 @@ async def setup_entity(var: MockObj, config: ConfigType, platform: str) -> None: device_name = device_id_obj.id # Set the entity name with pre-computed object_id hash - # We always pre-compute the hash using the same fallback logic as get_base_entity_object_id - # to ensure hash matches the object_id that would be generated + # Must match OLD behavior for bug-for-bug compatibility: + # - With MAC suffix: OLD code used App.get_friendly_name() directly (no fallback) + # - Without MAC suffix: OLD code used pre-computed object_id with fallback to device name entity_name = config[CONF_NAME] if entity_name: # Named entity - hash from entity name object_id_hash = fnv1_hash_object_id(entity_name) else: - # Empty name - use fallback logic: device_name -> friendly_name -> CORE.name + # Empty name - behavior depends on MAC suffix setting if device_name: + # Entity on sub-device - use device name base_name = device_name + elif CORE.config.get("name_add_mac_suffix", False): + # MAC suffix enabled - OLD behavior used friendly_name directly (even if empty) + # This is bug-for-bug compatibility + base_name = CORE.friendly_name or "" elif CORE.friendly_name: + # No MAC suffix, friendly_name set - use it base_name = CORE.friendly_name else: + # No MAC suffix, no friendly_name - fallback to device name base_name = CORE.name object_id_hash = fnv1_hash_object_id(base_name) add(var.set_name(entity_name, object_id_hash)) diff --git a/tests/integration/fixtures/object_id_no_friendly_name_no_mac_suffix.yaml b/tests/integration/fixtures/object_id_no_friendly_name_no_mac_suffix.yaml new file mode 100644 index 0000000000..4a947e0f6a --- /dev/null +++ b/tests/integration/fixtures/object_id_no_friendly_name_no_mac_suffix.yaml @@ -0,0 +1,25 @@ +esphome: + name: test-device + # No friendly_name set, no MAC suffix + # OLD behavior: object_id = device name because Python pre-computed with fallback + +host: + +api: + +logger: + +sensor: + # Empty name entity - OLD behavior used device name as fallback + - platform: template + name: "" + id: sensor_empty_name + lambda: return 42.0; + update_interval: 60s + + # Named entity for comparison + - platform: template + name: "Temperature" + id: sensor_named + lambda: return 43.0; + update_interval: 60s diff --git a/tests/integration/fixtures/object_id_no_friendly_name_with_mac_suffix.yaml b/tests/integration/fixtures/object_id_no_friendly_name_with_mac_suffix.yaml new file mode 100644 index 0000000000..ab12e670a0 --- /dev/null +++ b/tests/integration/fixtures/object_id_no_friendly_name_with_mac_suffix.yaml @@ -0,0 +1,26 @@ +esphome: + name: test-device + # No friendly_name set, MAC suffix enabled + # OLD behavior: object_id = "" (empty) because is_object_id_dynamic_() used App.get_friendly_name() directly + name_add_mac_suffix: true + +host: + +api: + +logger: + +sensor: + # Empty name entity - OLD behavior produced empty object_id when MAC suffix enabled + - platform: template + name: "" + id: sensor_empty_name + lambda: return 42.0; + update_interval: 60s + + # Named entity for comparison + - platform: template + name: "Temperature" + id: sensor_named + lambda: return 43.0; + update_interval: 60s diff --git a/tests/integration/test_object_id_no_friendly_name.py b/tests/integration/test_object_id_no_friendly_name.py new file mode 100644 index 0000000000..8228c25222 --- /dev/null +++ b/tests/integration/test_object_id_no_friendly_name.py @@ -0,0 +1,138 @@ +"""Integration tests for object_id when friendly_name is not set. + +These tests verify bug-for-bug compatibility with the old behavior: + +1. With MAC suffix enabled + no friendly_name: + - OLD: is_object_id_dynamic_() was true, used App.get_friendly_name() directly + - OLD: object_id = "" (empty) because friendly_name was empty + - NEW: Must maintain same behavior for compatibility + +2. Without MAC suffix + no friendly_name: + - OLD: is_object_id_dynamic_() was false, used pre-computed object_id_c_str_ + - OLD: Python computed object_id with fallback to device name + - NEW: Must maintain same behavior (object_id = device name) +""" + +from __future__ import annotations + +import pytest + +from esphome.helpers import fnv1_hash_object_id, sanitize, snake_case + +from .types import APIClientConnectedFactory, RunCompiledFunction + +# Host platform default MAC: 98:35:69:ab:f6:79 -> suffix "abf679" +MAC_SUFFIX = "abf679" + +# FNV1 offset basis - hash of empty string +FNV1_OFFSET_BASIS = 2166136261 + + +def compute_expected_object_id(name: str) -> str: + """Compute expected object_id from name using Python helpers.""" + return sanitize(snake_case(name)) + + +@pytest.mark.asyncio +async def test_object_id_no_friendly_name_with_mac_suffix( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test object_id when friendly_name not set but MAC suffix enabled. + + OLD behavior (bug-for-bug compatibility): + - is_object_id_dynamic_() returned true (no own name AND mac suffix enabled) + - format_dynamic_object_id() used App.get_friendly_name() directly + - Since friendly_name was empty, object_id was empty + + This was arguably a bug, but we maintain it for compatibility. + """ + async with run_compiled(yaml_config), api_client_connected() as client: + device_info = await client.device_info() + assert device_info is not None + + # Device name should include MAC suffix + expected_device_name = f"test-device-{MAC_SUFFIX}" + assert device_info.name == expected_device_name + + # Friendly name should be empty (not set in config) + assert device_info.friendly_name == "" + + entities, _ = await client.list_entities_services() + + # Find the empty-name entity + empty_name_entities = [e for e in entities if e.name == ""] + assert len(empty_name_entities) == 1 + + entity = empty_name_entities[0] + + # OLD behavior: object_id was empty because App.get_friendly_name() was empty + # This is bug-for-bug compatibility + assert entity.object_id == "", ( + f"Expected empty object_id for bug-for-bug compatibility, " + f"got '{entity.object_id}'" + ) + + # Hash should be FNV1_OFFSET_BASIS (hash of empty string) + assert entity.key == FNV1_OFFSET_BASIS, ( + f"Expected hash of empty string ({FNV1_OFFSET_BASIS:#x}), " + f"got {entity.key:#x}" + ) + + # Named entity should work normally + named_entities = [e for e in entities if e.name == "Temperature"] + assert len(named_entities) == 1 + assert named_entities[0].object_id == "temperature" + + +@pytest.mark.asyncio +async def test_object_id_no_friendly_name_no_mac_suffix( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test object_id when friendly_name not set and no MAC suffix. + + OLD behavior: + - is_object_id_dynamic_() returned false (mac suffix not enabled) + - Used object_id_c_str_ which was pre-computed in Python + - Python used get_base_entity_object_id() with fallback to CORE.name + + Result: object_id = sanitize(snake_case(device_name)) + """ + async with run_compiled(yaml_config), api_client_connected() as client: + device_info = await client.device_info() + assert device_info is not None + + # Device name should NOT include MAC suffix + assert device_info.name == "test-device" + + # Friendly name should be empty (not set in config) + assert device_info.friendly_name == "" + + entities, _ = await client.list_entities_services() + + # Find the empty-name entity + empty_name_entities = [e for e in entities if e.name == ""] + assert len(empty_name_entities) == 1 + + entity = empty_name_entities[0] + + # OLD behavior: object_id was computed from device name + expected_object_id = compute_expected_object_id("test-device") + assert entity.object_id == expected_object_id, ( + f"Expected object_id '{expected_object_id}' from device name, " + f"got '{entity.object_id}'" + ) + + # Hash should match device name + expected_hash = fnv1_hash_object_id("test-device") + assert entity.key == expected_hash, ( + f"Expected hash {expected_hash:#x}, got {entity.key:#x}" + ) + + # Named entity should work normally + named_entities = [e for e in entities if e.name == "Temperature"] + assert len(named_entities) == 1 + assert named_entities[0].object_id == "temperature" From fa39b6bebd38136e4ea4cdd4b809b537be288806 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 23 Dec 2025 00:16:53 -1000 Subject: [PATCH 18/25] fixes --- esphome/core/entity_helpers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/core/entity_helpers.py b/esphome/core/entity_helpers.py index ac23ea6d34..1ddba8caf6 100644 --- a/esphome/core/entity_helpers.py +++ b/esphome/core/entity_helpers.py @@ -96,7 +96,7 @@ async def setup_entity(var: MockObj, config: ConfigType, platform: str) -> None: if device_name: # Entity on sub-device - use device name base_name = device_name - elif CORE.config.get("name_add_mac_suffix", False): + elif CORE.config and CORE.config.get("name_add_mac_suffix", False): # MAC suffix enabled - OLD behavior used friendly_name directly (even if empty) # This is bug-for-bug compatibility base_name = CORE.friendly_name or "" From 83598d6798683cd385265ce7a9860599ff9b95b0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 23 Dec 2025 00:21:20 -1000 Subject: [PATCH 19/25] cover --- tests/unit_tests/core/test_entity_helpers.py | 112 ++++++++++++++++++- 1 file changed, 111 insertions(+), 1 deletion(-) diff --git a/tests/unit_tests/core/test_entity_helpers.py b/tests/unit_tests/core/test_entity_helpers.py index 0bc86eec8d..28bed5e3f3 100644 --- a/tests/unit_tests/core/test_entity_helpers.py +++ b/tests/unit_tests/core/test_entity_helpers.py @@ -23,7 +23,7 @@ from esphome.core.entity_helpers import ( setup_entity, ) from esphome.cpp_generator import MockObj -from esphome.helpers import sanitize, snake_case +from esphome.helpers import fnv1_hash_object_id, sanitize, snake_case from .common import load_config_from_fixture @@ -760,3 +760,113 @@ def test_entity_duplicate_validator_same_name_no_enhanced_message() -> None: r"Each entity on a device must have a unique name within its platform\.$", ): validator(config2) + + +@pytest.mark.asyncio +async def test_setup_entity_empty_name_with_device( + setup_test_environment: list[str], +) -> None: + """Test setup_entity with empty entity name on a sub-device. + + This covers lines 96-98: when entity has empty name and device_id is set, + the object_id hash should be computed from the device name. + """ + added_expressions = setup_test_environment + + # Mock get_variable to return a mock device + original_get_variable = entity_helpers.get_variable + + async def mock_get_variable(id_: ID) -> MockObj: + return MockObj("sub_device_1") + + entity_helpers.get_variable = mock_get_variable + + var = MockObj("sensor1") + device_id = ID("sub_device_1", type="Device") + + config = { + CONF_NAME: "", + CONF_DISABLED_BY_DEFAULT: False, + CONF_DEVICE_ID: device_id, + } + + await setup_entity(var, config, "sensor") + + entity_helpers.get_variable = original_get_variable + + # Check that set_device was called + assert any("sensor1.set_device" in expr for expr in added_expressions) + + # Verify the hash was computed from the device name + expected_hash = fnv1_hash_object_id("sub_device_1") + assert any( + "sensor1.set_name" in expr and str(expected_hash) in expr + for expr in added_expressions + ), f"Expected hash {expected_hash} not found in {added_expressions}" + + +@pytest.mark.asyncio +async def test_setup_entity_empty_name_with_mac_suffix( + setup_test_environment: list[str], +) -> None: + """Test setup_entity with empty name and MAC suffix enabled. + + This covers lines 99-102: when entity has empty name and name_add_mac_suffix + is enabled, the object_id hash should be computed from friendly_name directly + (even if empty) for bug-for-bug compatibility. + """ + added_expressions = setup_test_environment + + # Set up CORE.config with name_add_mac_suffix enabled + CORE.config = {"name_add_mac_suffix": True} + # Set friendly_name to a specific value + CORE.friendly_name = "My Device" + + var = MockObj("sensor1") + + config = { + CONF_NAME: "", + CONF_DISABLED_BY_DEFAULT: False, + } + + await setup_entity(var, config, "sensor") + + # Verify the hash was computed from friendly_name + expected_hash = fnv1_hash_object_id("My Device") + assert any( + "sensor1.set_name" in expr and str(expected_hash) in expr + for expr in added_expressions + ), f"Expected hash {expected_hash} not found in {added_expressions}" + + +@pytest.mark.asyncio +async def test_setup_entity_empty_name_with_mac_suffix_no_friendly_name( + setup_test_environment: list[str], +) -> None: + """Test setup_entity with empty name, MAC suffix enabled, but no friendly_name. + + This covers the bug-for-bug compatibility case where MAC suffix is enabled + but friendly_name is empty - should result in empty object_id (hash of empty string). + """ + added_expressions = setup_test_environment + + # Set up CORE.config with name_add_mac_suffix enabled + CORE.config = {"name_add_mac_suffix": True} + # Set friendly_name to empty + CORE.friendly_name = "" + + var = MockObj("sensor1") + + config = { + CONF_NAME: "", + CONF_DISABLED_BY_DEFAULT: False, + } + + await setup_entity(var, config, "sensor") + + # Verify the hash was computed from empty string (bug-for-bug compat) + expected_hash = fnv1_hash_object_id("") + assert any( + "sensor1.set_name" in expr and str(expected_hash) in expr + for expr in added_expressions + ), f"Expected hash {expected_hash} not found in {added_expressions}" From 04a75cf200a946307d20a7474bd7023b60ace7d1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 23 Dec 2025 00:24:45 -1000 Subject: [PATCH 20/25] cover --- tests/unit_tests/core/test_entity_helpers.py | 58 ++++++++++++++++++-- 1 file changed, 54 insertions(+), 4 deletions(-) diff --git a/tests/unit_tests/core/test_entity_helpers.py b/tests/unit_tests/core/test_entity_helpers.py index 28bed5e3f3..08636e55e3 100644 --- a/tests/unit_tests/core/test_entity_helpers.py +++ b/tests/unit_tests/core/test_entity_helpers.py @@ -865,8 +865,58 @@ async def test_setup_entity_empty_name_with_mac_suffix_no_friendly_name( await setup_entity(var, config, "sensor") # Verify the hash was computed from empty string (bug-for-bug compat) + # FNV1 offset basis (hash of empty string) = 2166136261 expected_hash = fnv1_hash_object_id("") - assert any( - "sensor1.set_name" in expr and str(expected_hash) in expr - for expr in added_expressions - ), f"Expected hash {expected_hash} not found in {added_expressions}" + assert expected_hash == 2166136261, ( + "Hash of empty string should be FNV1 offset basis" + ) + + # Verify the exact expression: set_name("", 2166136261UL) + set_name_expr = next( + (expr for expr in added_expressions if "sensor1.set_name" in expr), None + ) + assert set_name_expr is not None, "set_name call not found" + assert f'set_name("", {expected_hash}' in set_name_expr, ( + f"Expected set_name with empty string and hash {expected_hash}, " + f"got: {set_name_expr}" + ) + + +@pytest.mark.asyncio +async def test_setup_entity_empty_name_no_mac_suffix_no_friendly_name( + setup_test_environment: list[str], +) -> None: + """Test setup_entity with empty name, no MAC suffix, and no friendly_name. + + This covers lines 107-108: when entity has empty name, no MAC suffix, + and no friendly_name, it should fall back to CORE.name (device name). + """ + added_expressions = setup_test_environment + + # No MAC suffix (either not set or False) + CORE.config = {} + # No friendly_name + CORE.friendly_name = "" + # Device name is set + CORE.name = "my-test-device" + + var = MockObj("sensor1") + + config = { + CONF_NAME: "", + CONF_DISABLED_BY_DEFAULT: False, + } + + await setup_entity(var, config, "sensor") + + # Verify the hash was computed from CORE.name (device name fallback) + expected_hash = fnv1_hash_object_id("my-test-device") + + set_name_expr = next( + (expr for expr in added_expressions if "sensor1.set_name" in expr), None + ) + assert set_name_expr is not None, "set_name call not found" + assert f'set_name("", {expected_hash}' in set_name_expr, ( + f"Expected set_name with empty string and hash {expected_hash} " + f"(from device name 'my-test-device'), got: {set_name_expr}" + ) From c265436b07bcc18aac19901942bca9ccbe7a6d5e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 23 Dec 2025 00:45:25 -1000 Subject: [PATCH 21/25] cover --- .../test_object_id_api_verification.py | 34 +++++++---- .../test_object_id_no_friendly_name.py | 59 +++++++++++++++++++ 2 files changed, 82 insertions(+), 11 deletions(-) diff --git a/tests/integration/test_object_id_api_verification.py b/tests/integration/test_object_id_api_verification.py index c19c3a22df..d9846ad12d 100644 --- a/tests/integration/test_object_id_api_verification.py +++ b/tests/integration/test_object_id_api_verification.py @@ -175,30 +175,42 @@ async def test_object_id_api_verification( ) # === Test 3: Verify ALL entities can have object_id computed from API data === - # This is the key property for removing object_id from the API protocol + # This uses the algorithm from the PR summary that aioesphomeapi will use. + # NOTE: `name_add_mac_suffix` needs to be added to DeviceInfoResponse. + # For now, we infer it from the device name ending with MAC suffix. + mac_suffix = device_info.mac_address.replace(":", "")[-6:].lower() + name_add_mac_suffix = device_info.name.endswith(f"-{mac_suffix}") + for entity in entities: if entity.name: - # Named entity - use entity name - name_for_object_id = entity.name - elif entity.device_id == 0: - # Empty name on main device - use friendly_name - name_for_object_id = device_info.friendly_name + # Named entity: use entity name + name_for_id = entity.name + elif entity.device_id != 0: + # Empty name on sub-device: use sub-device name + name_for_id = device_id_to_name[entity.device_id] + elif name_add_mac_suffix: + # Empty name on main device with MAC suffix: use friendly_name directly + # (even if empty - this is bug-for-bug compatibility) + name_for_id = device_info.friendly_name + elif device_info.friendly_name: + # Empty name on main device with friendly_name set: use it + name_for_id = device_info.friendly_name else: - # Empty name on sub-device - use device name - name_for_object_id = device_id_to_name[entity.device_id] + # Empty name on main device, no friendly_name: use device name + name_for_id = device_info.name # Compute object_id from the appropriate name - computed_object_id = compute_expected_object_id(name_for_object_id) + computed_object_id = compute_expected_object_id(name_for_id) # Verify it matches what the API returned assert entity.object_id == computed_object_id, ( f"Entity (name='{entity.name}', device_id={entity.device_id}): " f"object_id cannot be computed. " - f"API: '{entity.object_id}', Computed from '{name_for_object_id}': '{computed_object_id}'" + f"API: '{entity.object_id}', Computed from '{name_for_id}': '{computed_object_id}'" ) # Verify hash can also be computed - computed_hash = fnv1_hash_object_id(name_for_object_id) + computed_hash = fnv1_hash_object_id(name_for_id) assert entity.key == computed_hash, ( f"Entity (name='{entity.name}', device_id={entity.device_id}): " f"hash cannot be computed. " diff --git a/tests/integration/test_object_id_no_friendly_name.py b/tests/integration/test_object_id_no_friendly_name.py index 8228c25222..73586dc785 100644 --- a/tests/integration/test_object_id_no_friendly_name.py +++ b/tests/integration/test_object_id_no_friendly_name.py @@ -85,6 +85,35 @@ async def test_object_id_no_friendly_name_with_mac_suffix( assert len(named_entities) == 1 assert named_entities[0].object_id == "temperature" + # Verify the full algorithm from PR summary works for ALL entities + # NOTE: `name_add_mac_suffix` needs to be added to DeviceInfoResponse. + # For now, we infer it from the device name ending with MAC suffix. + mac_suffix = device_info.mac_address.replace(":", "")[-6:].lower() + name_add_mac_suffix = device_info.name.endswith(f"-{mac_suffix}") + + for entity in entities: + if entity.name: + name_for_id = entity.name + elif name_add_mac_suffix: + # MAC suffix enabled: use friendly_name directly (even if empty) + name_for_id = device_info.friendly_name + elif device_info.friendly_name: + name_for_id = device_info.friendly_name + else: + name_for_id = device_info.name + + computed_object_id = compute_expected_object_id(name_for_id) + assert entity.object_id == computed_object_id, ( + f"Algorithm failed for entity '{entity.name}': " + f"expected '{computed_object_id}', got '{entity.object_id}'" + ) + + computed_hash = fnv1_hash_object_id(name_for_id) + assert entity.key == computed_hash, ( + f"Algorithm hash failed for entity '{entity.name}': " + f"expected {computed_hash:#x}, got {entity.key:#x}" + ) + @pytest.mark.asyncio async def test_object_id_no_friendly_name_no_mac_suffix( @@ -136,3 +165,33 @@ async def test_object_id_no_friendly_name_no_mac_suffix( named_entities = [e for e in entities if e.name == "Temperature"] assert len(named_entities) == 1 assert named_entities[0].object_id == "temperature" + + # Verify the full algorithm from PR summary works for ALL entities + # NOTE: `name_add_mac_suffix` needs to be added to DeviceInfoResponse. + # For now, we infer it from the device name ending with MAC suffix. + mac_suffix = device_info.mac_address.replace(":", "")[-6:].lower() + name_add_mac_suffix = device_info.name.endswith(f"-{mac_suffix}") + + for entity in entities: + if entity.name: + name_for_id = entity.name + elif name_add_mac_suffix: + # MAC suffix enabled: use friendly_name directly (even if empty) + name_for_id = device_info.friendly_name + elif device_info.friendly_name: + name_for_id = device_info.friendly_name + else: + # No MAC suffix, no friendly_name: use device name + name_for_id = device_info.name + + computed_object_id = compute_expected_object_id(name_for_id) + assert entity.object_id == computed_object_id, ( + f"Algorithm failed for entity '{entity.name}': " + f"expected '{computed_object_id}', got '{entity.object_id}'" + ) + + computed_hash = fnv1_hash_object_id(name_for_id) + assert entity.key == computed_hash, ( + f"Algorithm hash failed for entity '{entity.name}': " + f"expected {computed_hash:#x}, got {entity.key:#x}" + ) From 0ec741c425a77f1326a5c0b2bd12c4e0b713d305 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 23 Dec 2025 00:48:25 -1000 Subject: [PATCH 22/25] one more case --- ...object_id_friendly_name_no_mac_suffix.yaml | 27 +++++ ...t_object_id_friendly_name_no_mac_suffix.py | 107 ++++++++++++++++++ 2 files changed, 134 insertions(+) create mode 100644 tests/integration/fixtures/object_id_friendly_name_no_mac_suffix.yaml create mode 100644 tests/integration/test_object_id_friendly_name_no_mac_suffix.py diff --git a/tests/integration/fixtures/object_id_friendly_name_no_mac_suffix.yaml b/tests/integration/fixtures/object_id_friendly_name_no_mac_suffix.yaml new file mode 100644 index 0000000000..7a86e37d08 --- /dev/null +++ b/tests/integration/fixtures/object_id_friendly_name_no_mac_suffix.yaml @@ -0,0 +1,27 @@ +esphome: + name: test-device + # friendly_name set but NO MAC suffix + # Empty-name entity should use friendly_name for object_id + friendly_name: My Friendly Device + +host: + +api: + +logger: + +sensor: + # Empty name entity - should use friendly_name for object_id + # friendly_name = "My Friendly Device" -> object_id = "my_friendly_device" + - platform: template + name: "" + id: sensor_empty_name + lambda: return 42.0; + update_interval: 60s + + # Named entity for comparison + - platform: template + name: "Temperature" + id: sensor_named + lambda: return 43.0; + update_interval: 60s diff --git a/tests/integration/test_object_id_friendly_name_no_mac_suffix.py b/tests/integration/test_object_id_friendly_name_no_mac_suffix.py new file mode 100644 index 0000000000..40066532e7 --- /dev/null +++ b/tests/integration/test_object_id_friendly_name_no_mac_suffix.py @@ -0,0 +1,107 @@ +"""Integration test for object_id with friendly_name but no MAC suffix. + +This test covers Branch 4 of the algorithm: +- Empty name on main device +- NO MAC suffix enabled +- friendly_name IS set +- Result: use friendly_name for object_id +""" + +from __future__ import annotations + +import pytest + +from esphome.helpers import fnv1_hash_object_id, sanitize, snake_case + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +def compute_expected_object_id(name: str) -> str: + """Compute expected object_id from name using Python helpers.""" + return sanitize(snake_case(name)) + + +@pytest.mark.asyncio +async def test_object_id_friendly_name_no_mac_suffix( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test object_id when friendly_name is set but no MAC suffix. + + This covers Branch 4 of the algorithm: + - Empty name entity + - name_add_mac_suffix = false (or not set) + - friendly_name = "My Friendly Device" + - Expected: object_id = "my_friendly_device" + """ + async with run_compiled(yaml_config), api_client_connected() as client: + device_info = await client.device_info() + assert device_info is not None + + # Device name should NOT include MAC suffix + assert device_info.name == "test-device" + + # Friendly name should be set + assert device_info.friendly_name == "My Friendly Device" + + entities, _ = await client.list_entities_services() + + # Find the empty-name entity + empty_name_entities = [e for e in entities if e.name == ""] + assert len(empty_name_entities) == 1 + + entity = empty_name_entities[0] + + # Should use friendly_name for object_id (Branch 4) + expected_object_id = compute_expected_object_id("My Friendly Device") + assert expected_object_id == "my_friendly_device" # Verify our expectation + assert entity.object_id == expected_object_id, ( + f"Expected object_id '{expected_object_id}' from friendly_name, " + f"got '{entity.object_id}'" + ) + + # Hash should match friendly_name + expected_hash = fnv1_hash_object_id("My Friendly Device") + assert entity.key == expected_hash, ( + f"Expected hash {expected_hash:#x}, got {entity.key:#x}" + ) + + # Named entity should work normally + named_entities = [e for e in entities if e.name == "Temperature"] + assert len(named_entities) == 1 + assert named_entities[0].object_id == "temperature" + + # Verify the full algorithm from PR summary works for ALL entities + # NOTE: `name_add_mac_suffix` needs to be added to DeviceInfoResponse. + # For now, we infer it from the device name ending with MAC suffix. + mac_suffix = device_info.mac_address.replace(":", "")[-6:].lower() + name_add_mac_suffix = device_info.name.endswith(f"-{mac_suffix}") + + # Verify our inference: no MAC suffix in this test + assert not name_add_mac_suffix, "Device name should NOT have MAC suffix" + + for entity in entities: + if entity.name: + name_for_id = entity.name + elif name_add_mac_suffix: + # MAC suffix enabled: use friendly_name directly (even if empty) + name_for_id = device_info.friendly_name + elif device_info.friendly_name: + # Branch 4: No MAC suffix, but friendly_name is set + name_for_id = device_info.friendly_name + else: + # No MAC suffix, no friendly_name: use device name + name_for_id = device_info.name + + computed_object_id = compute_expected_object_id(name_for_id) + assert entity.object_id == computed_object_id, ( + f"Algorithm failed for entity '{entity.name}': " + f"expected '{computed_object_id}', got '{entity.object_id}'" + ) + + computed_hash = fnv1_hash_object_id(name_for_id) + assert entity.key == computed_hash, ( + f"Algorithm hash failed for entity '{entity.name}': " + f"expected {computed_hash:#x}, got {entity.key:#x}" + ) From 89ef5239905f69aa4c37af2247863a5367e509b8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 23 Dec 2025 01:01:20 -1000 Subject: [PATCH 23/25] tweak --- tests/integration/test_object_id_api_verification.py | 3 +-- .../test_object_id_friendly_name_no_mac_suffix.py | 3 +-- tests/integration/test_object_id_no_friendly_name.py | 6 ++---- 3 files changed, 4 insertions(+), 8 deletions(-) diff --git a/tests/integration/test_object_id_api_verification.py b/tests/integration/test_object_id_api_verification.py index d9846ad12d..58862bd234 100644 --- a/tests/integration/test_object_id_api_verification.py +++ b/tests/integration/test_object_id_api_verification.py @@ -176,8 +176,7 @@ async def test_object_id_api_verification( # === Test 3: Verify ALL entities can have object_id computed from API data === # This uses the algorithm from the PR summary that aioesphomeapi will use. - # NOTE: `name_add_mac_suffix` needs to be added to DeviceInfoResponse. - # For now, we infer it from the device name ending with MAC suffix. + # Infer name_add_mac_suffix from device name ending with MAC suffix. mac_suffix = device_info.mac_address.replace(":", "")[-6:].lower() name_add_mac_suffix = device_info.name.endswith(f"-{mac_suffix}") diff --git a/tests/integration/test_object_id_friendly_name_no_mac_suffix.py b/tests/integration/test_object_id_friendly_name_no_mac_suffix.py index 40066532e7..b8d198f9d0 100644 --- a/tests/integration/test_object_id_friendly_name_no_mac_suffix.py +++ b/tests/integration/test_object_id_friendly_name_no_mac_suffix.py @@ -73,8 +73,7 @@ async def test_object_id_friendly_name_no_mac_suffix( assert named_entities[0].object_id == "temperature" # Verify the full algorithm from PR summary works for ALL entities - # NOTE: `name_add_mac_suffix` needs to be added to DeviceInfoResponse. - # For now, we infer it from the device name ending with MAC suffix. + # Infer name_add_mac_suffix from device name ending with MAC suffix. mac_suffix = device_info.mac_address.replace(":", "")[-6:].lower() name_add_mac_suffix = device_info.name.endswith(f"-{mac_suffix}") diff --git a/tests/integration/test_object_id_no_friendly_name.py b/tests/integration/test_object_id_no_friendly_name.py index 73586dc785..1a60a787ed 100644 --- a/tests/integration/test_object_id_no_friendly_name.py +++ b/tests/integration/test_object_id_no_friendly_name.py @@ -86,8 +86,7 @@ async def test_object_id_no_friendly_name_with_mac_suffix( assert named_entities[0].object_id == "temperature" # Verify the full algorithm from PR summary works for ALL entities - # NOTE: `name_add_mac_suffix` needs to be added to DeviceInfoResponse. - # For now, we infer it from the device name ending with MAC suffix. + # Infer name_add_mac_suffix from device name ending with MAC suffix. mac_suffix = device_info.mac_address.replace(":", "")[-6:].lower() name_add_mac_suffix = device_info.name.endswith(f"-{mac_suffix}") @@ -167,8 +166,7 @@ async def test_object_id_no_friendly_name_no_mac_suffix( assert named_entities[0].object_id == "temperature" # Verify the full algorithm from PR summary works for ALL entities - # NOTE: `name_add_mac_suffix` needs to be added to DeviceInfoResponse. - # For now, we infer it from the device name ending with MAC suffix. + # Infer name_add_mac_suffix from device name ending with MAC suffix. mac_suffix = device_info.mac_address.replace(":", "")[-6:].lower() name_add_mac_suffix = device_info.name.endswith(f"-{mac_suffix}") From 38beb613c2869428c2e2bb536c2d3867832ce77d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 23 Dec 2025 07:45:46 -1000 Subject: [PATCH 24/25] simplify --- esphome/core/entity_helpers.py | 29 ++------ tests/unit_tests/core/test_entity_helpers.py | 71 +++++++------------- 2 files changed, 28 insertions(+), 72 deletions(-) diff --git a/esphome/core/entity_helpers.py b/esphome/core/entity_helpers.py index 1ddba8caf6..c1801c0bda 100644 --- a/esphome/core/entity_helpers.py +++ b/esphome/core/entity_helpers.py @@ -76,37 +76,16 @@ async def setup_entity(var: MockObj, config: ConfigType, platform: str) -> None: platform: The platform name (e.g., "sensor", "binary_sensor") """ # Get device info if configured - device_name: str | None = None - device_id_obj: ID | None if device_id_obj := config.get(CONF_DEVICE_ID): device: MockObj = await get_variable(device_id_obj) add(var.set_device(device)) - device_name = device_id_obj.id # Set the entity name with pre-computed object_id hash - # Must match OLD behavior for bug-for-bug compatibility: - # - With MAC suffix: OLD code used App.get_friendly_name() directly (no fallback) - # - Without MAC suffix: OLD code used pre-computed object_id with fallback to device name + # For named entities: pre-compute hash from entity name + # For empty-name entities: pass 0, C++ calculates hash at runtime from + # device name, friendly_name, or app name (bug-for-bug compatibility) entity_name = config[CONF_NAME] - if entity_name: - # Named entity - hash from entity name - object_id_hash = fnv1_hash_object_id(entity_name) - else: - # Empty name - behavior depends on MAC suffix setting - if device_name: - # Entity on sub-device - use device name - base_name = device_name - elif CORE.config and CORE.config.get("name_add_mac_suffix", False): - # MAC suffix enabled - OLD behavior used friendly_name directly (even if empty) - # This is bug-for-bug compatibility - base_name = CORE.friendly_name or "" - elif CORE.friendly_name: - # No MAC suffix, friendly_name set - use it - base_name = CORE.friendly_name - else: - # No MAC suffix, no friendly_name - fallback to device name - base_name = CORE.name - object_id_hash = fnv1_hash_object_id(base_name) + object_id_hash = fnv1_hash_object_id(entity_name) if entity_name else 0 add(var.set_name(entity_name, object_id_hash)) # Only set disabled_by_default if True (default is False) if config[CONF_DISABLED_BY_DEFAULT]: diff --git a/tests/unit_tests/core/test_entity_helpers.py b/tests/unit_tests/core/test_entity_helpers.py index 08636e55e3..a58d4784ce 100644 --- a/tests/unit_tests/core/test_entity_helpers.py +++ b/tests/unit_tests/core/test_entity_helpers.py @@ -23,7 +23,7 @@ from esphome.core.entity_helpers import ( setup_entity, ) from esphome.cpp_generator import MockObj -from esphome.helpers import fnv1_hash_object_id, sanitize, snake_case +from esphome.helpers import sanitize, snake_case from .common import load_config_from_fixture @@ -768,8 +768,8 @@ async def test_setup_entity_empty_name_with_device( ) -> None: """Test setup_entity with empty entity name on a sub-device. - This covers lines 96-98: when entity has empty name and device_id is set, - the object_id hash should be computed from the device name. + For empty-name entities, Python passes 0 and C++ calculates the hash + at runtime from the device's actual name. """ added_expressions = setup_test_environment @@ -797,12 +797,10 @@ async def test_setup_entity_empty_name_with_device( # Check that set_device was called assert any("sensor1.set_device" in expr for expr in added_expressions) - # Verify the hash was computed from the device name - expected_hash = fnv1_hash_object_id("sub_device_1") - assert any( - "sensor1.set_name" in expr and str(expected_hash) in expr - for expr in added_expressions - ), f"Expected hash {expected_hash} not found in {added_expressions}" + # For empty-name entities, Python passes 0 - C++ calculates hash at runtime + assert any('set_name("", 0)' in expr for expr in added_expressions), ( + f"Expected set_name with hash 0, got {added_expressions}" + ) @pytest.mark.asyncio @@ -811,9 +809,8 @@ async def test_setup_entity_empty_name_with_mac_suffix( ) -> None: """Test setup_entity with empty name and MAC suffix enabled. - This covers lines 99-102: when entity has empty name and name_add_mac_suffix - is enabled, the object_id hash should be computed from friendly_name directly - (even if empty) for bug-for-bug compatibility. + For empty-name entities, Python passes 0 and C++ calculates the hash + at runtime from friendly_name (bug-for-bug compatibility). """ added_expressions = setup_test_environment @@ -831,12 +828,10 @@ async def test_setup_entity_empty_name_with_mac_suffix( await setup_entity(var, config, "sensor") - # Verify the hash was computed from friendly_name - expected_hash = fnv1_hash_object_id("My Device") - assert any( - "sensor1.set_name" in expr and str(expected_hash) in expr - for expr in added_expressions - ), f"Expected hash {expected_hash} not found in {added_expressions}" + # For empty-name entities, Python passes 0 - C++ calculates hash at runtime + assert any('set_name("", 0)' in expr for expr in added_expressions), ( + f"Expected set_name with hash 0, got {added_expressions}" + ) @pytest.mark.asyncio @@ -845,8 +840,9 @@ async def test_setup_entity_empty_name_with_mac_suffix_no_friendly_name( ) -> None: """Test setup_entity with empty name, MAC suffix enabled, but no friendly_name. - This covers the bug-for-bug compatibility case where MAC suffix is enabled - but friendly_name is empty - should result in empty object_id (hash of empty string). + For empty-name entities, Python passes 0 and C++ calculates the hash + at runtime. In this case C++ will hash the empty friendly_name + (bug-for-bug compatibility). """ added_expressions = setup_test_environment @@ -864,21 +860,9 @@ async def test_setup_entity_empty_name_with_mac_suffix_no_friendly_name( await setup_entity(var, config, "sensor") - # Verify the hash was computed from empty string (bug-for-bug compat) - # FNV1 offset basis (hash of empty string) = 2166136261 - expected_hash = fnv1_hash_object_id("") - assert expected_hash == 2166136261, ( - "Hash of empty string should be FNV1 offset basis" - ) - - # Verify the exact expression: set_name("", 2166136261UL) - set_name_expr = next( - (expr for expr in added_expressions if "sensor1.set_name" in expr), None - ) - assert set_name_expr is not None, "set_name call not found" - assert f'set_name("", {expected_hash}' in set_name_expr, ( - f"Expected set_name with empty string and hash {expected_hash}, " - f"got: {set_name_expr}" + # For empty-name entities, Python passes 0 - C++ calculates hash at runtime + assert any('set_name("", 0)' in expr for expr in added_expressions), ( + f"Expected set_name with hash 0, got {added_expressions}" ) @@ -888,8 +872,8 @@ async def test_setup_entity_empty_name_no_mac_suffix_no_friendly_name( ) -> None: """Test setup_entity with empty name, no MAC suffix, and no friendly_name. - This covers lines 107-108: when entity has empty name, no MAC suffix, - and no friendly_name, it should fall back to CORE.name (device name). + For empty-name entities, Python passes 0 and C++ calculates the hash + at runtime from the device name. """ added_expressions = setup_test_environment @@ -909,14 +893,7 @@ async def test_setup_entity_empty_name_no_mac_suffix_no_friendly_name( await setup_entity(var, config, "sensor") - # Verify the hash was computed from CORE.name (device name fallback) - expected_hash = fnv1_hash_object_id("my-test-device") - - set_name_expr = next( - (expr for expr in added_expressions if "sensor1.set_name" in expr), None - ) - assert set_name_expr is not None, "set_name call not found" - assert f'set_name("", {expected_hash}' in set_name_expr, ( - f"Expected set_name with empty string and hash {expected_hash} " - f"(from device name 'my-test-device'), got: {set_name_expr}" + # For empty-name entities, Python passes 0 - C++ calculates hash at runtime + assert any('set_name("", 0)' in expr for expr in added_expressions), ( + f"Expected set_name with hash 0, got {added_expressions}" ) From 8505a4dfaf6a7a3b3c002599b8394446132734e5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 23 Dec 2025 07:52:33 -1000 Subject: [PATCH 25/25] dry up tests --- tests/integration/conftest.py | 3 + tests/integration/entity_utils.py | 144 ++++++++++++++++++ .../test_object_id_api_verification.py | 57 +------ ...t_object_id_friendly_name_no_mac_suffix.py | 49 ++---- .../test_object_id_no_friendly_name.py | 69 +-------- 5 files changed, 174 insertions(+), 148 deletions(-) create mode 100644 tests/integration/entity_utils.py diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 965363972f..50e8d4122b 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -51,6 +51,9 @@ if platform.system() == "Windows": import pty # not available on Windows +# Register assert rewrite for entity_utils so assertions have proper error messages +pytest.register_assert_rewrite("tests.integration.entity_utils") + def _get_platformio_env(cache_dir: Path) -> dict[str, str]: """Get environment variables for PlatformIO with shared cache.""" diff --git a/tests/integration/entity_utils.py b/tests/integration/entity_utils.py new file mode 100644 index 0000000000..f0164341e3 --- /dev/null +++ b/tests/integration/entity_utils.py @@ -0,0 +1,144 @@ +"""Utilities for computing entity object_id in integration tests. + +This module contains the algorithm that aioesphomeapi will use to compute +object_id client-side from API data. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from esphome.helpers import fnv1_hash_object_id, sanitize, snake_case + +if TYPE_CHECKING: + from aioesphomeapi import DeviceInfo, EntityInfo + + +def compute_object_id(name: str) -> str: + """Compute object_id from name using snake_case + sanitize.""" + return sanitize(snake_case(name)) + + +def infer_name_add_mac_suffix(device_info: DeviceInfo) -> bool: + """Infer name_add_mac_suffix from device name ending with MAC suffix.""" + mac_suffix = device_info.mac_address.replace(":", "")[-6:].lower() + return device_info.name.endswith(f"-{mac_suffix}") + + +def compute_entity_object_id( + entity: EntityInfo, + device_info: DeviceInfo, + device_id_to_name: dict[int, str], +) -> str: + """Compute expected object_id for an entity using the algorithm from PR summary. + + This is the algorithm that aioesphomeapi will use to compute object_id + client-side from API data. + + Args: + entity: The entity to compute object_id for + device_info: Device info from the API + device_id_to_name: Mapping of device_id to device name for sub-devices + + Returns: + The computed object_id string + """ + name_add_mac_suffix = infer_name_add_mac_suffix(device_info) + + if entity.name: + # Named entity: use entity name + name_for_id = entity.name + elif entity.device_id != 0: + # Empty name on sub-device: use sub-device name + name_for_id = device_id_to_name[entity.device_id] + elif name_add_mac_suffix: + # Empty name on main device with MAC suffix: use friendly_name directly + # (even if empty - this is bug-for-bug compatibility) + name_for_id = device_info.friendly_name + elif device_info.friendly_name: + # Empty name on main device with friendly_name set: use it + name_for_id = device_info.friendly_name + else: + # Empty name on main device, no friendly_name: use device name + name_for_id = device_info.name + + return compute_object_id(name_for_id) + + +def compute_entity_hash( + entity: EntityInfo, + device_info: DeviceInfo, + device_id_to_name: dict[int, str], +) -> int: + """Compute expected object_id hash for an entity. + + Args: + entity: The entity to compute hash for + device_info: Device info from the API + device_id_to_name: Mapping of device_id to device name for sub-devices + + Returns: + The computed FNV-1 hash + """ + name_add_mac_suffix = infer_name_add_mac_suffix(device_info) + + if entity.name: + name_for_id = entity.name + elif entity.device_id != 0: + name_for_id = device_id_to_name[entity.device_id] + elif name_add_mac_suffix or device_info.friendly_name: + name_for_id = device_info.friendly_name + else: + name_for_id = device_info.name + + return fnv1_hash_object_id(name_for_id) + + +def verify_entity_object_id( + entity: EntityInfo, + device_info: DeviceInfo, + device_id_to_name: dict[int, str], +) -> None: + """Verify an entity's object_id and hash match the expected values. + + Args: + entity: The entity to verify + device_info: Device info from the API + device_id_to_name: Mapping of device_id to device name for sub-devices + + Raises: + AssertionError: If object_id or hash doesn't match expected value + """ + expected_object_id = compute_entity_object_id( + entity, device_info, device_id_to_name + ) + assert entity.object_id == expected_object_id, ( + f"object_id mismatch for entity '{entity.name}': " + f"expected '{expected_object_id}', got '{entity.object_id}'" + ) + + expected_hash = compute_entity_hash(entity, device_info, device_id_to_name) + assert entity.key == expected_hash, ( + f"hash mismatch for entity '{entity.name}': " + f"expected {expected_hash:#x}, got {entity.key:#x}" + ) + + +def verify_all_entities( + entities: list[EntityInfo], + device_info: DeviceInfo, +) -> None: + """Verify all entities have correct object_id and hash values. + + Args: + entities: List of entities to verify + device_info: Device info from the API + + Raises: + AssertionError: If any entity's object_id or hash doesn't match + """ + # Build device_id -> name lookup from sub-devices + device_id_to_name = {d.device_id: d.name for d in device_info.devices} + + for entity in entities: + verify_entity_object_id(entity, device_info, device_id_to_name) diff --git a/tests/integration/test_object_id_api_verification.py b/tests/integration/test_object_id_api_verification.py index 58862bd234..c8603e0682 100644 --- a/tests/integration/test_object_id_api_verification.py +++ b/tests/integration/test_object_id_api_verification.py @@ -25,8 +25,9 @@ from __future__ import annotations import pytest -from esphome.helpers import fnv1_hash_object_id, sanitize, snake_case +from esphome.helpers import fnv1_hash_object_id +from .entity_utils import compute_object_id, verify_all_entities from .types import APIClientConnectedFactory, RunCompiledFunction # Host platform default MAC: 98:35:69:ab:f6:79 -> suffix "abf679" @@ -62,11 +63,6 @@ SUB_DEVICE_EMPTY_NAME_ENTITIES = [ ] -def compute_expected_object_id(name: str) -> str: - """Compute expected object_id from name using Python helpers.""" - return sanitize(snake_case(name)) - - @pytest.mark.asyncio async def test_object_id_api_verification( yaml_config: str, @@ -120,7 +116,7 @@ async def test_object_id_api_verification( ) # Verify Python computation matches - computed = compute_expected_object_id(entity_name) + computed = compute_object_id(entity_name) assert computed == expected_object_id, ( f"Entity '{entity_name}': Python computation mismatch. " f"Computed '{computed}', expected '{expected_object_id}'" @@ -160,7 +156,7 @@ async def test_object_id_api_verification( ) expected_name = device_id_to_name[entity.device_id] - expected_object_id = compute_expected_object_id(expected_name) + expected_object_id = compute_object_id(expected_name) assert entity.object_id == expected_object_id, ( f"Empty-name entity (device_id={entity.device_id}): object_id mismatch. " f"API: '{entity.object_id}', expected: '{expected_object_id}' " @@ -174,44 +170,7 @@ async def test_object_id_api_verification( f"API key: {entity.key:#x}, expected: {expected_hash:#x}" ) - # === Test 3: Verify ALL entities can have object_id computed from API data === - # This uses the algorithm from the PR summary that aioesphomeapi will use. - # Infer name_add_mac_suffix from device name ending with MAC suffix. - mac_suffix = device_info.mac_address.replace(":", "")[-6:].lower() - name_add_mac_suffix = device_info.name.endswith(f"-{mac_suffix}") - - for entity in entities: - if entity.name: - # Named entity: use entity name - name_for_id = entity.name - elif entity.device_id != 0: - # Empty name on sub-device: use sub-device name - name_for_id = device_id_to_name[entity.device_id] - elif name_add_mac_suffix: - # Empty name on main device with MAC suffix: use friendly_name directly - # (even if empty - this is bug-for-bug compatibility) - name_for_id = device_info.friendly_name - elif device_info.friendly_name: - # Empty name on main device with friendly_name set: use it - name_for_id = device_info.friendly_name - else: - # Empty name on main device, no friendly_name: use device name - name_for_id = device_info.name - - # Compute object_id from the appropriate name - computed_object_id = compute_expected_object_id(name_for_id) - - # Verify it matches what the API returned - assert entity.object_id == computed_object_id, ( - f"Entity (name='{entity.name}', device_id={entity.device_id}): " - f"object_id cannot be computed. " - f"API: '{entity.object_id}', Computed from '{name_for_id}': '{computed_object_id}'" - ) - - # Verify hash can also be computed - computed_hash = fnv1_hash_object_id(name_for_id) - assert entity.key == computed_hash, ( - f"Entity (name='{entity.name}', device_id={entity.device_id}): " - f"hash cannot be computed. " - f"API key: {entity.key:#x}, Computed: {computed_hash:#x}" - ) + # === Test 3: Verify ALL entities using the algorithm from entity_utils === + # This uses the algorithm that aioesphomeapi will use to compute object_id + # client-side from API data. + verify_all_entities(entities, device_info) diff --git a/tests/integration/test_object_id_friendly_name_no_mac_suffix.py b/tests/integration/test_object_id_friendly_name_no_mac_suffix.py index b8d198f9d0..7199a2b371 100644 --- a/tests/integration/test_object_id_friendly_name_no_mac_suffix.py +++ b/tests/integration/test_object_id_friendly_name_no_mac_suffix.py @@ -11,16 +11,16 @@ from __future__ import annotations import pytest -from esphome.helpers import fnv1_hash_object_id, sanitize, snake_case +from esphome.helpers import fnv1_hash_object_id +from .entity_utils import ( + compute_object_id, + infer_name_add_mac_suffix, + verify_all_entities, +) from .types import APIClientConnectedFactory, RunCompiledFunction -def compute_expected_object_id(name: str) -> str: - """Compute expected object_id from name using Python helpers.""" - return sanitize(snake_case(name)) - - @pytest.mark.asyncio async def test_object_id_friendly_name_no_mac_suffix( yaml_config: str, @@ -54,7 +54,7 @@ async def test_object_id_friendly_name_no_mac_suffix( entity = empty_name_entities[0] # Should use friendly_name for object_id (Branch 4) - expected_object_id = compute_expected_object_id("My Friendly Device") + expected_object_id = compute_object_id("My Friendly Device") assert expected_object_id == "my_friendly_device" # Verify our expectation assert entity.object_id == expected_object_id, ( f"Expected object_id '{expected_object_id}' from friendly_name, " @@ -72,35 +72,10 @@ async def test_object_id_friendly_name_no_mac_suffix( assert len(named_entities) == 1 assert named_entities[0].object_id == "temperature" - # Verify the full algorithm from PR summary works for ALL entities - # Infer name_add_mac_suffix from device name ending with MAC suffix. - mac_suffix = device_info.mac_address.replace(":", "")[-6:].lower() - name_add_mac_suffix = device_info.name.endswith(f"-{mac_suffix}") - # Verify our inference: no MAC suffix in this test - assert not name_add_mac_suffix, "Device name should NOT have MAC suffix" + assert not infer_name_add_mac_suffix(device_info), ( + "Device name should NOT have MAC suffix" + ) - for entity in entities: - if entity.name: - name_for_id = entity.name - elif name_add_mac_suffix: - # MAC suffix enabled: use friendly_name directly (even if empty) - name_for_id = device_info.friendly_name - elif device_info.friendly_name: - # Branch 4: No MAC suffix, but friendly_name is set - name_for_id = device_info.friendly_name - else: - # No MAC suffix, no friendly_name: use device name - name_for_id = device_info.name - - computed_object_id = compute_expected_object_id(name_for_id) - assert entity.object_id == computed_object_id, ( - f"Algorithm failed for entity '{entity.name}': " - f"expected '{computed_object_id}', got '{entity.object_id}'" - ) - - computed_hash = fnv1_hash_object_id(name_for_id) - assert entity.key == computed_hash, ( - f"Algorithm hash failed for entity '{entity.name}': " - f"expected {computed_hash:#x}, got {entity.key:#x}" - ) + # Verify the full algorithm from entity_utils works for ALL entities + verify_all_entities(entities, device_info) diff --git a/tests/integration/test_object_id_no_friendly_name.py b/tests/integration/test_object_id_no_friendly_name.py index 1a60a787ed..b548f02fde 100644 --- a/tests/integration/test_object_id_no_friendly_name.py +++ b/tests/integration/test_object_id_no_friendly_name.py @@ -17,8 +17,9 @@ from __future__ import annotations import pytest -from esphome.helpers import fnv1_hash_object_id, sanitize, snake_case +from esphome.helpers import fnv1_hash_object_id +from .entity_utils import compute_object_id, verify_all_entities from .types import APIClientConnectedFactory, RunCompiledFunction # Host platform default MAC: 98:35:69:ab:f6:79 -> suffix "abf679" @@ -28,11 +29,6 @@ MAC_SUFFIX = "abf679" FNV1_OFFSET_BASIS = 2166136261 -def compute_expected_object_id(name: str) -> str: - """Compute expected object_id from name using Python helpers.""" - return sanitize(snake_case(name)) - - @pytest.mark.asyncio async def test_object_id_no_friendly_name_with_mac_suffix( yaml_config: str, @@ -85,33 +81,8 @@ async def test_object_id_no_friendly_name_with_mac_suffix( assert len(named_entities) == 1 assert named_entities[0].object_id == "temperature" - # Verify the full algorithm from PR summary works for ALL entities - # Infer name_add_mac_suffix from device name ending with MAC suffix. - mac_suffix = device_info.mac_address.replace(":", "")[-6:].lower() - name_add_mac_suffix = device_info.name.endswith(f"-{mac_suffix}") - - for entity in entities: - if entity.name: - name_for_id = entity.name - elif name_add_mac_suffix: - # MAC suffix enabled: use friendly_name directly (even if empty) - name_for_id = device_info.friendly_name - elif device_info.friendly_name: - name_for_id = device_info.friendly_name - else: - name_for_id = device_info.name - - computed_object_id = compute_expected_object_id(name_for_id) - assert entity.object_id == computed_object_id, ( - f"Algorithm failed for entity '{entity.name}': " - f"expected '{computed_object_id}', got '{entity.object_id}'" - ) - - computed_hash = fnv1_hash_object_id(name_for_id) - assert entity.key == computed_hash, ( - f"Algorithm hash failed for entity '{entity.name}': " - f"expected {computed_hash:#x}, got {entity.key:#x}" - ) + # Verify the full algorithm from entity_utils works for ALL entities + verify_all_entities(entities, device_info) @pytest.mark.asyncio @@ -148,7 +119,7 @@ async def test_object_id_no_friendly_name_no_mac_suffix( entity = empty_name_entities[0] # OLD behavior: object_id was computed from device name - expected_object_id = compute_expected_object_id("test-device") + expected_object_id = compute_object_id("test-device") assert entity.object_id == expected_object_id, ( f"Expected object_id '{expected_object_id}' from device name, " f"got '{entity.object_id}'" @@ -165,31 +136,5 @@ async def test_object_id_no_friendly_name_no_mac_suffix( assert len(named_entities) == 1 assert named_entities[0].object_id == "temperature" - # Verify the full algorithm from PR summary works for ALL entities - # Infer name_add_mac_suffix from device name ending with MAC suffix. - mac_suffix = device_info.mac_address.replace(":", "")[-6:].lower() - name_add_mac_suffix = device_info.name.endswith(f"-{mac_suffix}") - - for entity in entities: - if entity.name: - name_for_id = entity.name - elif name_add_mac_suffix: - # MAC suffix enabled: use friendly_name directly (even if empty) - name_for_id = device_info.friendly_name - elif device_info.friendly_name: - name_for_id = device_info.friendly_name - else: - # No MAC suffix, no friendly_name: use device name - name_for_id = device_info.name - - computed_object_id = compute_expected_object_id(name_for_id) - assert entity.object_id == computed_object_id, ( - f"Algorithm failed for entity '{entity.name}': " - f"expected '{computed_object_id}', got '{entity.object_id}'" - ) - - computed_hash = fnv1_hash_object_id(name_for_id) - assert entity.key == computed_hash, ( - f"Algorithm hash failed for entity '{entity.name}': " - f"expected {computed_hash:#x}, got {entity.key:#x}" - ) + # Verify the full algorithm from entity_utils works for ALL entities + verify_all_entities(entities, device_info)