diff --git a/esphome/core/entity_base.cpp b/esphome/core/entity_base.cpp index 5c4e1c44459..3274640eb34 100644 --- a/esphome/core/entity_base.cpp +++ b/esphome/core/entity_base.cpp @@ -10,8 +10,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) { this->set_name(name, 0); } -void EntityBase::set_name(const char *name, uint32_t object_id_hash) { + +void EntityBase::configure_entity_(const char *name, uint32_t object_id_hash, uint32_t entity_strings_packed) { this->name_ = StringRef(name); if (this->name_.empty()) { #ifdef USE_DEVICES @@ -44,6 +44,17 @@ void EntityBase::set_name(const char *name, uint32_t object_id_hash) { this->calc_object_id_(); } } + // Unpack entity string table indices. + // Packed: [23..16] icon | [15..8] UoM | [7..0] device_class (each 8 bits) +#ifdef USE_ENTITY_DEVICE_CLASS + this->device_class_idx_ = entity_strings_packed & 0xFF; +#endif +#ifdef USE_ENTITY_UNIT_OF_MEASUREMENT + this->uom_idx_ = (entity_strings_packed >> 8) & 0xFF; +#endif +#ifdef USE_ENTITY_ICON + this->icon_idx_ = (entity_strings_packed >> 16) & 0xFF; +#endif } // Weak default lookup functions — overridden by generated code in main.cpp diff --git a/esphome/core/entity_base.h b/esphome/core/entity_base.h index 20eb68b67a7..accd532b0d0 100644 --- a/esphome/core/entity_base.h +++ b/esphome/core/entity_base.h @@ -12,6 +12,10 @@ #include "device.h" #endif +// Forward declarations for friend access from codegen-generated setup() +void setup(); // NOLINT(readability-redundant-declaration) - may be declared in Arduino.h +void original_setup(); // NOLINT(readability-redundant-declaration) - used by cpp unit tests + namespace esphome { // Extern lookup functions for entity string tables. @@ -54,12 +58,8 @@ enum EntityCategory : uint8_t { // The generic Entity base class that provides an interface common to all Entities. class EntityBase { public: - // Get/set the name of this Entity + // Get 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; } @@ -104,20 +104,6 @@ class EntityBase { this->flags_.entity_category = static_cast(entity_category); } - // Set entity string table indices — one call per entity from codegen. - // Packed: [23..16] icon | [15..8] UoM | [7..0] device_class (each 8 bits) - void set_entity_strings([[maybe_unused]] uint32_t packed) { -#ifdef USE_ENTITY_DEVICE_CLASS - this->device_class_idx_ = packed & 0xFF; -#endif -#ifdef USE_ENTITY_UNIT_OF_MEASUREMENT - this->uom_idx_ = (packed >> 8) & 0xFF; -#endif -#ifdef USE_ENTITY_ICON - this->icon_idx_ = (packed >> 16) & 0xFF; -#endif - } - // Get this entity's device class into a stack buffer. // On non-ESP8266: returns pointer to PROGMEM string directly (buffer unused). // On ESP8266: copies from PROGMEM to buffer, returns buffer pointer. @@ -239,6 +225,12 @@ class EntityBase { } protected: + friend void ::setup(); + friend void ::original_setup(); + + /// Combined entity setup from codegen: set name, object_id hash, and entity string indices. + void configure_entity_(const char *name, uint32_t object_id_hash, uint32_t entity_strings_packed); + /// Non-template helper for make_entity_preference() to avoid code bloat. /// When preference hash algorithm changes, migration logic goes here. ESPPreferenceObject make_entity_preference_(size_t size, uint32_t version); diff --git a/esphome/core/entity_helpers.py b/esphome/core/entity_helpers.py index a46d2466fdf..4fa109fb0e1 100644 --- a/esphome/core/entity_helpers.py +++ b/esphome/core/entity_helpers.py @@ -31,8 +31,10 @@ DOMAIN = "entity_string_pool" _KEY_DC_IDX = "_entity_dc_idx" _KEY_UOM_IDX = "_entity_uom_idx" _KEY_ICON_IDX = "_entity_icon_idx" +_KEY_ENTITY_NAME = "_entity_name" +_KEY_OBJECT_ID_HASH = "_entity_object_id_hash" -# Bit layout for set_entity_strings(packed) — must match C++ setter in entity_base.h: +# Bit layout for entity_strings_packed in configure_entity_() — must match C++ in entity_base.h: # [23..16] icon (8 bits) | [15..8] UoM (8 bits) | [7..0] device_class (8 bits) _DC_SHIFT = 0 _UOM_SHIFT = 8 @@ -219,17 +221,18 @@ def setup_unit_of_measurement(config: ConfigType) -> None: def finalize_entity_strings(var: MockObj, config: ConfigType) -> None: - """Emit a single set_entity_strings() call with all packed indices. + """Emit a single configure_entity_() call with name, hash, and packed string indices. Call this at the end of each component's setup function, after setup_entity() and any register_device_class/register_unit_of_measurement calls. """ + entity_name = config[_KEY_ENTITY_NAME] + object_id_hash = config[_KEY_OBJECT_ID_HASH] dc_idx = config.get(_KEY_DC_IDX, 0) uom_idx = config.get(_KEY_UOM_IDX, 0) icon_idx = config.get(_KEY_ICON_IDX, 0) packed = (dc_idx << _DC_SHIFT) | (uom_idx << _UOM_SHIFT) | (icon_idx << _ICON_SHIFT) - if packed != 0: - add(var.set_entity_strings(packed)) + add(var.configure_entity_(entity_name, object_id_hash, packed)) def get_base_entity_object_id( @@ -331,13 +334,15 @@ async def _setup_entity_impl(var: MockObj, config: ConfigType, platform: str) -> device: MockObj = await get_variable(device_id_obj) add(var.set_device(device)) - # Set the entity name with pre-computed object_id hash + # Pre-compute entity name and object_id hash for configure_entity_() + # which is emitted later by finalize_entity_strings(). # 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] object_id_hash = fnv1_hash_object_id(entity_name) if entity_name else 0 - add(var.set_name(entity_name, object_id_hash)) + config[_KEY_ENTITY_NAME] = entity_name + config[_KEY_OBJECT_ID_HASH] = 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)) diff --git a/tests/component_tests/binary_sensor/test_binary_sensor.py b/tests/component_tests/binary_sensor/test_binary_sensor.py index ce4e64681fe..fbc2f37d9a1 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("test bs1",' in main_cpp + assert 'bs_1->configure_entity_("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 797b6fb1a42..9f94d61c8c4 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("wol_test_1",' in main_cpp + assert 'wol_1->configure_entity_("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/sensor/test_sensor.py b/tests/component_tests/sensor/test_sensor.py index 221e7edf2c3..d9ab3a022c8 100644 --- a/tests/component_tests/sensor/test_sensor.py +++ b/tests/component_tests/sensor/test_sensor.py @@ -1,5 +1,15 @@ """Tests for the sensor component.""" +import re + + +def _extract_packed_value(main_cpp, var_name): + """Extract the third (packed) argument from a configure_entity_ call.""" + pattern = rf"{re.escape(var_name)}->configure_entity_\([^,]+,\s*\w+,\s*(\d+)\)" + match = re.search(pattern, main_cpp) + assert match, f"configure_entity_ call not found for {var_name}" + return int(match.group(1)) + def test_sensor_device_class_set(generate_main): """ @@ -10,5 +20,6 @@ def test_sensor_device_class_set(generate_main): # When main_cpp = generate_main("tests/component_tests/sensor/test_sensor.yaml") - # Then - assert "s_1->set_entity_strings(" in main_cpp + # Then: device_class: voltage means packed value must be non-zero + packed = _extract_packed_value(main_cpp, "s_1") + assert packed != 0 diff --git a/tests/component_tests/text/test_text.py b/tests/component_tests/text/test_text.py index 16f5f980a5f..3ceaa9b8f81 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("test 1 text",' in main_cpp + assert 'it_1->configure_entity_("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 4aaebe04d1c..f30b820e94d 100644 --- a/tests/component_tests/text_sensor/test_text_sensor.py +++ b/tests/component_tests/text_sensor/test_text_sensor.py @@ -1,5 +1,15 @@ """Tests for the text sensor component.""" +import re + + +def _extract_packed_value(main_cpp, var_name): + """Extract the third (packed) argument from a configure_entity_ call.""" + pattern = rf"{re.escape(var_name)}->configure_entity_\([^,]+,\s*\w+,\s*(\d+)\)" + match = re.search(pattern, main_cpp) + assert match, f"configure_entity_ call not found for {var_name}" + return int(match.group(1)) + def test_text_sensor_is_setup(generate_main): """ @@ -25,9 +35,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("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 + assert 'ts_1->configure_entity_("Template Text Sensor 1",' in main_cpp + assert 'ts_2->configure_entity_("Template Text Sensor 2",' in main_cpp + assert 'ts_3->configure_entity_("Template Text Sensor 3",' in main_cpp def test_text_sensor_config_value_internal_set(generate_main): @@ -53,6 +63,9 @@ def test_text_sensor_device_class_set(generate_main): # When main_cpp = generate_main("tests/component_tests/text_sensor/test_text_sensor.yaml") - # Then - assert "ts_2->set_entity_strings(" in main_cpp - assert "ts_3->set_entity_strings(" in main_cpp + # Then: ts_2 has device_class: timestamp, ts_3 has device_class: date + # so their packed values must be non-zero + packed_ts_2 = _extract_packed_value(main_cpp, "ts_2") + assert packed_ts_2 != 0 + packed_ts_3 = _extract_packed_value(main_cpp, "ts_3") + assert packed_ts_3 != 0 diff --git a/tests/unit_tests/core/test_entity_helpers.py b/tests/unit_tests/core/test_entity_helpers.py index 1392a1d0436..3f6faaee54c 100644 --- a/tests/unit_tests/core/test_entity_helpers.py +++ b/tests/unit_tests/core/test_entity_helpers.py @@ -32,9 +32,11 @@ from esphome.helpers import sanitize, snake_case from .common import load_config_from_fixture -# 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\(["\']([^"\']*)["\']') +# Pre-compiled regex pattern for extracting names from configure_entity_/set_name calls +# Matches: .configure_entity_("name", ...) or .set_name("name", ...) +ENTITY_NAME_PATTERN = re.compile( + r'\.(?:configure_entity_|set_name)\(["\']([^"\']*)["\']' +) FIXTURES_DIR = Path(__file__).parent.parent / "fixtures" / "core" / "entity_helpers" @@ -276,15 +278,23 @@ def setup_test_environment() -> Generator[list[str], None, None]: entity_helpers.add = original_add -def extract_object_id_from_expressions(expressions: list[str]) -> str | None: - """Extract the object ID that would be computed from set_name calls. +def extract_object_id_from_config(config: dict[str, Any]) -> str | None: + """Extract the object ID from config keys set by _setup_entity_impl.""" + name = config.get("_entity_name") + if name is None: + return 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 - 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. - """ + +def extract_object_id_from_expressions(expressions: list[str]) -> str | None: + """Extract the object ID from configure_entity_() calls in generated expressions.""" for expr in expressions: - if match := SET_NAME_PATTERN.search(expr): + if match := ENTITY_NAME_PATTERN.search(expr): name = match.group(1) if name: return sanitize(snake_case(name)) @@ -299,8 +309,6 @@ def extract_object_id_from_expressions(expressions: list[str]) -> str | None: async def test_setup_entity_no_duplicates(setup_test_environment: list[str]) -> None: """Test setup_entity with unique names.""" - added_expressions = setup_test_environment - # Create mock entities var1 = MockObj("sensor1") var2 = MockObj("sensor2") @@ -312,13 +320,10 @@ async def test_setup_entity_no_duplicates(setup_test_environment: list[str]) -> } await _setup_entity_impl(var1, config1, "sensor") - # Get object ID from first entity - object_id1 = extract_object_id_from_expressions(added_expressions) + # Get object ID from first entity (stored in config, emitted later by finalize) + object_id1 = extract_object_id_from_config(config1) assert object_id1 == "temperature" - # Clear for next entity - added_expressions.clear() - # Set up second entity with different name config2 = { CONF_NAME: "Humidity", @@ -327,7 +332,7 @@ async def test_setup_entity_no_duplicates(setup_test_environment: list[str]) -> await _setup_entity_impl(var2, config2, "sensor") # Get object ID from second entity - object_id2 = extract_object_id_from_expressions(added_expressions) + object_id2 = extract_object_id_from_config(config2) assert object_id2 == "humidity" @@ -337,8 +342,6 @@ async def test_setup_entity_different_platforms( ) -> None: """Test that same name on different platforms doesn't conflict.""" - added_expressions = setup_test_environment - # Create mock entities sensor = MockObj("sensor1") binary_sensor = MockObj("binary_sensor1") @@ -356,15 +359,11 @@ async def test_setup_entity_different_platforms( (text_sensor, "text_sensor"), ] - object_ids: list[str] = [] for var, platform in platforms: - added_expressions.clear() await _setup_entity_impl(var, config, platform) - object_id = extract_object_id_from_expressions(added_expressions) - object_ids.append(object_id) - # All should get base object ID without suffix - assert all(obj_id == "status" for obj_id in object_ids) + # All should get the same object ID (name stored in config, not platform-specific) + assert extract_object_id_from_config(config) == "status" @pytest.fixture @@ -389,7 +388,6 @@ async def test_setup_entity_with_devices( setup_test_environment: list[str], mock_get_variable: dict[ID, MockObj] ) -> None: """Test that same name on different devices doesn't conflict.""" - added_expressions = setup_test_environment # Create mock devices device1_id = ID("device1", type="Device") @@ -418,24 +416,18 @@ async def test_setup_entity_with_devices( } # Get object IDs - object_ids: list[str] = [] for var, config in [(sensor1, config1), (sensor2, config2)]: - added_expressions.clear() await _setup_entity_impl(var, config, "sensor") - object_id = extract_object_id_from_expressions(added_expressions) - object_ids.append(object_id) # Both should get base object ID without suffix (different devices) - assert object_ids[0] == "temperature" - assert object_ids[1] == "temperature" + assert extract_object_id_from_config(config1) == "temperature" + assert extract_object_id_from_config(config2) == "temperature" @pytest.mark.asyncio async def test_setup_entity_empty_name(setup_test_environment: list[str]) -> None: """Test setup_entity with empty entity name.""" - added_expressions = setup_test_environment - var = MockObj("sensor1") config = { @@ -445,7 +437,7 @@ async def test_setup_entity_empty_name(setup_test_environment: list[str]) -> Non await _setup_entity_impl(var, config, "sensor") - object_id = extract_object_id_from_expressions(added_expressions) + object_id = extract_object_id_from_config(config) # Should use friendly name assert object_id == "test_device" @@ -456,8 +448,6 @@ async def test_setup_entity_special_characters( ) -> None: """Test setup_entity with names containing special characters.""" - added_expressions = setup_test_environment - var = MockObj("sensor1") config = { @@ -466,7 +456,7 @@ async def test_setup_entity_special_characters( } await _setup_entity_impl(var, config, "sensor") - object_id = extract_object_id_from_expressions(added_expressions) + object_id = extract_object_id_from_config(config) # Special characters should be sanitized assert object_id == "temperature_sensor_" @@ -476,8 +466,6 @@ async def test_setup_entity_special_characters( async def test_setup_entity_with_icon(setup_test_environment: list[str]) -> None: """Test setup_entity sets icon correctly.""" - setup_test_environment # noqa: F841 - fixture initializes CORE state - var = MockObj("sensor1") config = { @@ -800,10 +788,9 @@ 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) - # 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}" - ) + # For empty-name entities, Python stores hash 0 - C++ calculates hash at runtime + assert config.get("_entity_name") == "" + assert config.get("_entity_object_id_hash") == 0 @pytest.mark.asyncio @@ -815,7 +802,6 @@ async def test_setup_entity_empty_name_with_mac_suffix( 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 # Set up CORE.config with name_add_mac_suffix enabled CORE.config = {"name_add_mac_suffix": True} @@ -831,10 +817,9 @@ async def test_setup_entity_empty_name_with_mac_suffix( await _setup_entity_impl(var, config, "sensor") - # 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}" - ) + # For empty-name entities, Python stores hash 0 - C++ calculates hash at runtime + assert config.get("_entity_name") == "" + assert config.get("_entity_object_id_hash") == 0 @pytest.mark.asyncio @@ -847,7 +832,6 @@ async def test_setup_entity_empty_name_with_mac_suffix_no_friendly_name( at runtime. In this case C++ will hash the empty friendly_name (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} @@ -863,10 +847,9 @@ async def test_setup_entity_empty_name_with_mac_suffix_no_friendly_name( await _setup_entity_impl(var, config, "sensor") - # 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}" - ) + # For empty-name entities, Python stores hash 0 - C++ calculates hash at runtime + assert config.get("_entity_name") == "" + assert config.get("_entity_object_id_hash") == 0 @pytest.mark.asyncio @@ -878,7 +861,6 @@ async def test_setup_entity_empty_name_no_mac_suffix_no_friendly_name( For empty-name entities, Python passes 0 and C++ calculates the hash at runtime from the device name. """ - added_expressions = setup_test_environment # No MAC suffix (either not set or False) CORE.config = {} @@ -896,10 +878,9 @@ async def test_setup_entity_empty_name_no_mac_suffix_no_friendly_name( await _setup_entity_impl(var, config, "sensor") - # 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}" - ) + # For empty-name entities, Python stores hash 0 - C++ calculates hash at runtime + assert config.get("_entity_name") == "" + assert config.get("_entity_object_id_hash") == 0 def test_register_string_overflow() -> None: @@ -976,7 +957,7 @@ async def test_setup_entity_direct_call(setup_test_environment: list[str]) -> No # Direct call mode: await setup_entity(var, config, "camera") await setup_entity(var, config, "camera") - # Should have called set_name + # Should have emitted configure_entity_ object_id = extract_object_id_from_expressions(added_expressions) assert object_id == "my_camera"