diff --git a/esphome/cpp_helpers.py b/esphome/cpp_helpers.py index ee91ac6132..a1289485ca 100644 --- a/esphome/cpp_helpers.py +++ b/esphome/cpp_helpers.py @@ -17,7 +17,7 @@ from esphome.core import CORE, ID, coroutine from esphome.coroutine import FakeAwaitable from esphome.cpp_generator import MockObj, add, get_variable from esphome.cpp_types import App -from esphome.helpers import sanitize, snake_case +from esphome.entity import get_base_entity_object_id from esphome.types import ConfigFragmentType, ConfigType from esphome.util import Registry, RegistryEntry @@ -122,19 +122,14 @@ async def setup_entity(var: MockObj, config: ConfigType, platform: str) -> None: add(var.set_name(config[CONF_NAME])) - # Calculate base object_id - base_object_id: str + # 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) + if not config[CONF_NAME]: - # Use the friendly name if available, otherwise use the device name - if CORE.friendly_name: - base_object_id = sanitize(snake_case(CORE.friendly_name)) - else: - base_object_id = sanitize(snake_case(CORE.name)) _LOGGER.debug( "Entity has empty name, using '%s' as object_id base", base_object_id ) - else: - base_object_id = sanitize(snake_case(config[CONF_NAME])) # Handle duplicates # Check for duplicates @@ -156,6 +151,12 @@ async def setup_entity(var: MockObj, config: ConfigType, platform: str) -> None: object_id = base_object_id add(var.set_object_id(object_id)) + _LOGGER.debug( + "Setting object_id '%s' for entity '%s' on platform '%s'", + object_id, + config[CONF_NAME], + platform, + ) add(var.set_disabled_by_default(config[CONF_DISABLED_BY_DEFAULT])) if CONF_INTERNAL in config: add(var.set_internal(config[CONF_INTERNAL])) diff --git a/esphome/entity.py b/esphome/entity.py new file mode 100644 index 0000000000..732822d0ff --- /dev/null +++ b/esphome/entity.py @@ -0,0 +1,41 @@ +"""Entity-related helper functions.""" + +from esphome.core import CORE +from esphome.helpers import sanitize, snake_case + + +def get_base_entity_object_id(name: str, friendly_name: str | None) -> str: + """Calculate the base object ID for an entity that will be set via set_object_id(). + + This function calculates what object_id_c_str_ should be set to in C++. + + The C++ EntityBase::get_object_id() (entity_base.cpp lines 38-49) works as: + - If !has_own_name && is_name_add_mac_suffix_enabled(): + return str_sanitize(str_snake_case(App.get_friendly_name())) // Dynamic + - Else: + return object_id_c_str_ ?? "" // What we set via set_object_id() + + Since we're calculating what to pass to set_object_id(), we always need to + generate the object_id the same way, regardless of name_add_mac_suffix setting. + + Args: + name: The entity name (empty string if no name) + friendly_name: The friendly name from CORE.friendly_name + + Returns: + The base object ID to use for duplicate checking and to pass to set_object_id() + """ + + if name: + # Entity has its own name (has_own_name will be true) + base_str = name + elif friendly_name: + # Entity has empty name (has_own_name will be false) + # Calculate what the object_id should be + # C++ uses App.get_friendly_name() which returns friendly_name or device name + base_str = friendly_name + else: + # Fallback to device name + base_str = CORE.name + + return sanitize(snake_case(base_str)) diff --git a/tests/integration/fixtures/duplicate_entities.yaml b/tests/integration/fixtures/duplicate_entities.yaml new file mode 100644 index 0000000000..0f831db90d --- /dev/null +++ b/tests/integration/fixtures/duplicate_entities.yaml @@ -0,0 +1,118 @@ +esphome: + name: duplicate-entities-test + # Define devices to test multi-device duplicate handling + devices: + - id: controller_1 + name: Controller 1 + - id: controller_2 + name: Controller 2 + +host: +api: # Port will be automatically injected +logger: + +# Create duplicate entities across different scenarios + +# Scenario 1: Multiple sensors with same name on same device (should get _2, _3, _4) +sensor: + - platform: template + name: Temperature + lambda: return 1.0; + update_interval: 0.1s + + - platform: template + name: Temperature + lambda: return 2.0; + update_interval: 0.1s + + - platform: template + name: Temperature + lambda: return 3.0; + update_interval: 0.1s + + - platform: template + name: Temperature + lambda: return 4.0; + update_interval: 0.1s + + # Scenario 2: Device-specific duplicates using device_id configuration + - platform: template + name: Device Temperature + device_id: controller_1 + lambda: return 10.0; + update_interval: 0.1s + + - platform: template + name: Device Temperature + device_id: controller_1 + lambda: return 11.0; + update_interval: 0.1s + + - platform: template + name: Device Temperature + device_id: controller_1 + lambda: return 12.0; + update_interval: 0.1s + + # Different device, same name - should not conflict + - platform: template + name: Device Temperature + device_id: controller_2 + lambda: return 20.0; + update_interval: 0.1s + +# Scenario 3: Binary sensors (different platform, same name) +binary_sensor: + - platform: template + name: Temperature + lambda: return true; + + - platform: template + name: Temperature + lambda: return false; + + - platform: template + name: Temperature + lambda: return true; + + # Scenario 5: Binary sensors on devices + - platform: template + name: Device Temperature + device_id: controller_1 + lambda: return true; + + - platform: template + name: Device Temperature + device_id: controller_2 + lambda: return false; + +# Scenario 6: Test with special characters that need sanitization +text_sensor: + - platform: template + name: "Status Message!" + lambda: return {"status1"}; + update_interval: 0.1s + + - platform: template + name: "Status Message!" + lambda: return {"status2"}; + update_interval: 0.1s + + - platform: template + name: "Status Message!" + lambda: return {"status3"}; + update_interval: 0.1s + +# Scenario 7: More switch duplicates +switch: + - platform: template + name: "Power Switch" + lambda: return false; + turn_on_action: [] + turn_off_action: [] + + - platform: template + name: "Power Switch" + lambda: return true; + turn_on_action: [] + turn_off_action: [] diff --git a/tests/integration/test_duplicate_entities.py b/tests/integration/test_duplicate_entities.py new file mode 100644 index 0000000000..edbcb9799c --- /dev/null +++ b/tests/integration/test_duplicate_entities.py @@ -0,0 +1,187 @@ +"""Integration test for duplicate entity handling.""" + +from __future__ import annotations + +import asyncio + +from aioesphomeapi import EntityInfo +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_duplicate_entities( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test that duplicate entity names are automatically suffixed with _2, _3, _4.""" + 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 + + # Get devices + devices = device_info.devices + assert len(devices) >= 2, f"Expected at least 2 devices, got {len(devices)}" + + # Find our test devices + controller_1 = next((d for d in devices if d.name == "Controller 1"), None) + controller_2 = next((d for d in devices if d.name == "Controller 2"), None) + + assert controller_1 is not None, "Controller 1 device not found" + assert controller_2 is not None, "Controller 2 device not found" + + # Get entity list + entities = await client.list_entities_services() + all_entities: list[EntityInfo] = [] + for entity_list in entities[0]: + if hasattr(entity_list, "object_id"): + all_entities.append(entity_list) + + # Group entities by type for easier testing + sensors = [e for e in all_entities if e.__class__.__name__ == "SensorInfo"] + binary_sensors = [ + e for e in all_entities if e.__class__.__name__ == "BinarySensorInfo" + ] + text_sensors = [ + e for e in all_entities if e.__class__.__name__ == "TextSensorInfo" + ] + switches = [e for e in all_entities if e.__class__.__name__ == "SwitchInfo"] + + # Scenario 1: Check sensors with duplicate "Temperature" names + temp_sensors = [s for s in sensors if s.name == "Temperature"] + temp_object_ids = sorted([s.object_id for s in temp_sensors]) + + # Should have temperature, temperature_2, temperature_3, temperature_4 + assert len(temp_object_ids) >= 4, ( + f"Expected at least 4 temperature sensors, got {len(temp_object_ids)}" + ) + assert "temperature" in temp_object_ids, ( + "First temperature sensor should not have suffix" + ) + assert "temperature_2" in temp_object_ids, ( + "Second temperature sensor should be temperature_2" + ) + assert "temperature_3" in temp_object_ids, ( + "Third temperature sensor should be temperature_3" + ) + assert "temperature_4" in temp_object_ids, ( + "Fourth temperature sensor should be temperature_4" + ) + + # Scenario 2: Check device-specific sensors don't conflict + device_temp_sensors = [s for s in sensors if s.name == "Device Temperature"] + + # Group by device + controller_1_temps = [ + s + for s in device_temp_sensors + if getattr(s, "device_id", None) == controller_1.device_id + ] + controller_2_temps = [ + s + for s in device_temp_sensors + if getattr(s, "device_id", None) == controller_2.device_id + ] + + # Controller 1 should have device_temperature, device_temperature_2, device_temperature_3 + c1_object_ids = sorted([s.object_id for s in controller_1_temps]) + assert len(c1_object_ids) >= 3, ( + f"Expected at least 3 sensors on controller_1, got {len(c1_object_ids)}" + ) + assert "device_temperature" in c1_object_ids, ( + "First device sensor should not have suffix" + ) + assert "device_temperature_2" in c1_object_ids, ( + "Second device sensor should be device_temperature_2" + ) + assert "device_temperature_3" in c1_object_ids, ( + "Third device sensor should be device_temperature_3" + ) + + # Controller 2 should have only device_temperature (no suffix) + c2_object_ids = [s.object_id for s in controller_2_temps] + assert len(c2_object_ids) >= 1, ( + f"Expected at least 1 sensor on controller_2, got {len(c2_object_ids)}" + ) + assert "device_temperature" in c2_object_ids, ( + "Controller 2 sensor should not have suffix" + ) + + # Scenario 3: Check binary sensors (different platform, same name) + temp_binary = [b for b in binary_sensors if b.name == "Temperature"] + binary_object_ids = sorted([b.object_id for b in temp_binary]) + + # Should have temperature, temperature_2, temperature_3 (no conflict with sensor platform) + assert len(binary_object_ids) >= 3, ( + f"Expected at least 3 binary sensors, got {len(binary_object_ids)}" + ) + assert "temperature" in binary_object_ids, ( + "First binary sensor should not have suffix" + ) + assert "temperature_2" in binary_object_ids, ( + "Second binary sensor should be temperature_2" + ) + assert "temperature_3" in binary_object_ids, ( + "Third binary sensor should be temperature_3" + ) + + # Scenario 4: Check text sensors with special characters + status_sensors = [t for t in text_sensors if t.name == "Status Message!"] + status_object_ids = sorted([t.object_id for t in status_sensors]) + + # Special characters should be sanitized to _ + assert len(status_object_ids) >= 3, ( + f"Expected at least 3 status sensors, got {len(status_object_ids)}" + ) + assert "status_message_" in status_object_ids, ( + "First status sensor should be status_message_" + ) + assert "status_message__2" in status_object_ids, ( + "Second status sensor should be status_message__2" + ) + assert "status_message__3" in status_object_ids, ( + "Third status sensor should be status_message__3" + ) + + # Scenario 5: Check switches with duplicate names + power_switches = [s for s in switches if s.name == "Power Switch"] + power_object_ids = sorted([s.object_id for s in power_switches]) + + # Should have power_switch, power_switch_2 + assert len(power_object_ids) >= 2, ( + f"Expected at least 2 power switches, got {len(power_object_ids)}" + ) + assert "power_switch" in power_object_ids, ( + "First power switch should be power_switch" + ) + assert "power_switch_2" in power_object_ids, ( + "Second power switch should be power_switch_2" + ) + + # Verify we can get states for all entities (ensures they're functional) + loop = asyncio.get_running_loop() + states_future: asyncio.Future[bool] = loop.create_future() + state_count = 0 + expected_count = ( + len(sensors) + len(binary_sensors) + len(text_sensors) + len(switches) + ) + + def on_state(state) -> None: + nonlocal state_count + state_count += 1 + if state_count >= expected_count and not states_future.done(): + states_future.set_result(True) + + client.subscribe_states(on_state) + + # Wait for all entity states + try: + await asyncio.wait_for(states_future, timeout=10.0) + except asyncio.TimeoutError: + pytest.fail( + f"Did not receive all entity states within 10 seconds. " + f"Expected {expected_count}, received {state_count}" + ) diff --git a/tests/unit_tests/test_get_base_entity_object_id.py b/tests/unit_tests/test_get_base_entity_object_id.py new file mode 100644 index 0000000000..aeea862d78 --- /dev/null +++ b/tests/unit_tests/test_get_base_entity_object_id.py @@ -0,0 +1,140 @@ +"""Test get_base_entity_object_id function matches C++ behavior.""" + +from esphome.core import CORE +from esphome.entity import get_base_entity_object_id +from esphome.helpers import sanitize, snake_case + + +class TestGetBaseEntityObjectId: + """Test that get_base_entity_object_id matches C++ EntityBase::get_object_id behavior.""" + + def test_with_entity_name(self) -> None: + """Test when entity has its own name - should use entity name.""" + # Simple name + assert ( + get_base_entity_object_id("Temperature Sensor", None) + == "temperature_sensor" + ) + assert ( + get_base_entity_object_id("Temperature Sensor", "Device Name") + == "temperature_sensor" + ) + + # Name with special characters + assert ( + get_base_entity_object_id("Temp!@#$%^&*()Sensor", None) + == "temp__________sensor" + ) + assert get_base_entity_object_id("Temp-Sensor_123", None) == "temp-sensor_123" + + # Already snake_case + assert ( + get_base_entity_object_id("temperature_sensor", None) + == "temperature_sensor" + ) + + # Mixed case + assert ( + get_base_entity_object_id("TemperatureSensor", None) == "temperaturesensor" + ) + assert ( + get_base_entity_object_id("TEMPERATURE SENSOR", None) + == "temperature_sensor" + ) + + def test_empty_name_with_friendly_name(self) -> None: + """Test when entity has empty name - should use friendly name.""" + # C++ behavior: when has_own_name is false, uses App.get_friendly_name() + assert get_base_entity_object_id("", "Friendly Device") == "friendly_device" + assert ( + get_base_entity_object_id("", "Kitchen Controller") == "kitchen_controller" + ) + assert get_base_entity_object_id("", "Test-Device_123") == "test-device_123" + + # Special characters in friendly name + assert get_base_entity_object_id("", "Device!@#$%") == "device_____" + + def test_empty_name_no_friendly_name(self) -> None: + """Test when entity has empty name and no friendly name - should use device name.""" + # Save original values + original_name = getattr(CORE, "name", None) + + try: + # Test with CORE.name set + CORE.name = "device-name" + assert get_base_entity_object_id("", None) == "device-name" + + CORE.name = "Test Device" + assert get_base_entity_object_id("", None) == "test_device" + + finally: + # Restore original value + if original_name is not None: + CORE.name = original_name + + def test_edge_cases(self) -> None: + """Test edge cases.""" + # Only spaces + assert get_base_entity_object_id(" ", None) == "___" + + # Unicode characters (should be replaced) + assert get_base_entity_object_id("Température", None) == "temp_rature" + assert get_base_entity_object_id("测试", None) == "__" + + # Empty string with empty friendly name (empty friendly name is treated as None) + # Falls back to CORE.name + original_name = getattr(CORE, "name", None) + try: + CORE.name = "device" + assert get_base_entity_object_id("", "") == "device" + finally: + if original_name is not None: + CORE.name = original_name + + # Very long name (should work fine) + long_name = "a" * 100 + " " + "b" * 100 + expected = "a" * 100 + "_" + "b" * 100 + assert get_base_entity_object_id(long_name, None) == expected + + def test_matches_cpp_helpers(self) -> None: + """Test that the logic matches using snake_case and sanitize directly.""" + test_cases = [ + ("Temperature Sensor", "temperature_sensor"), + ("Living Room Light", "living_room_light"), + ("Test-Device_123", "test-device_123"), + ("Special!@#Chars", "special___chars"), + ("UPPERCASE NAME", "uppercase_name"), + ("lowercase name", "lowercase_name"), + ("Mixed Case Name", "mixed_case_name"), + (" Spaces ", "___spaces___"), + ] + + for name, expected in test_cases: + # For non-empty names, verify our function produces same result as direct snake_case + sanitize + assert get_base_entity_object_id(name, None) == sanitize(snake_case(name)) + assert get_base_entity_object_id(name, None) == expected + + # Empty name is handled specially - it doesn't just use sanitize(snake_case("")) + # Instead it falls back to friendly_name or CORE.name + assert sanitize(snake_case("")) == "" # Direct conversion gives empty string + # But our function returns a fallback + original_name = getattr(CORE, "name", None) + try: + CORE.name = "device" + assert get_base_entity_object_id("", None) == "device" # Uses device name + finally: + if original_name is not None: + CORE.name = original_name + + def test_name_add_mac_suffix_behavior(self) -> None: + """Test behavior related to name_add_mac_suffix. + + In C++, when name_add_mac_suffix is enabled and entity has no name, + get_object_id() returns str_sanitize(str_snake_case(App.get_friendly_name())) + dynamically. Our function always returns the same result since we're + calculating the base for duplicate tracking. + """ + # The function should always return the same result regardless of + # name_add_mac_suffix setting, as we're calculating the base object_id + assert get_base_entity_object_id("", "Test Device") == "test_device" + assert get_base_entity_object_id("Entity Name", "Test Device") == "entity_name"