From 9a0ba1657e0140b90a03aa9b1dcc1af4a4c61ff2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 30 Jun 2025 21:38:19 -0500 Subject: [PATCH] Fix entity hash collisions by enforcing unique names across devices per platform (#9276) --- esphome/core/entity_helpers.py | 22 +-- ...ies_not_allowed_on_different_devices.yaml} | 80 ++++++---- tests/integration/test_duplicate_entities.py | 137 +++++++++++------- tests/unit_tests/core/test_entity_helpers.py | 40 +++-- 4 files changed, 175 insertions(+), 104 deletions(-) rename tests/integration/fixtures/{duplicate_entities_on_different_devices.yaml => duplicate_entities_not_allowed_on_different_devices.yaml} (61%) diff --git a/esphome/core/entity_helpers.py b/esphome/core/entity_helpers.py index c95acebbf9..2442fbca4b 100644 --- a/esphome/core/entity_helpers.py +++ b/esphome/core/entity_helpers.py @@ -184,25 +184,27 @@ def entity_duplicate_validator(platform: str) -> Callable[[ConfigType], ConfigTy # No name to validate return config - # Get the entity name and device info + # Get the entity name entity_name = config[CONF_NAME] - device_id = "" # Empty string for main device + # Get device name if entity is on a sub-device + device_name = None if CONF_DEVICE_ID in config: device_id_obj = config[CONF_DEVICE_ID] - # Use the device ID string directly for uniqueness - device_id = device_id_obj.id + device_name = device_id_obj.id - # For duplicate detection, just use the sanitized name - name_key = sanitize(snake_case(entity_name)) + # Calculate what object_id will actually be used + # This handles empty names correctly by using device/friendly names + name_key = get_base_entity_object_id( + entity_name, CORE.friendly_name, device_name + ) # Check for duplicates - unique_key = (device_id, platform, name_key) + unique_key = (platform, name_key) if unique_key in CORE.unique_ids: - device_prefix = f" on device '{device_id}'" if device_id else "" raise cv.Invalid( - f"Duplicate {platform} entity with name '{entity_name}' found{device_prefix}. " - f"Each entity on a device must have a unique name within its platform." + f"Duplicate {platform} entity with name '{entity_name}' found. " + f"Each entity must have a unique name within its platform across all devices." ) # Add to tracking set diff --git a/tests/integration/fixtures/duplicate_entities_on_different_devices.yaml b/tests/integration/fixtures/duplicate_entities_not_allowed_on_different_devices.yaml similarity index 61% rename from tests/integration/fixtures/duplicate_entities_on_different_devices.yaml rename to tests/integration/fixtures/duplicate_entities_not_allowed_on_different_devices.yaml index ecc502ad28..f7d017a0ae 100644 --- a/tests/integration/fixtures/duplicate_entities_on_different_devices.yaml +++ b/tests/integration/fixtures/duplicate_entities_not_allowed_on_different_devices.yaml @@ -1,6 +1,6 @@ esphome: name: duplicate-entities-test - # Define devices to test multi-device duplicate handling + # Define devices to test multi-device unique name validation devices: - id: controller_1 name: Controller 1 @@ -13,31 +13,31 @@ host: api: # Port will be automatically injected logger: -# Test that duplicate entity names are allowed on different devices +# Test that duplicate entity names are NOT allowed on different devices -# Scenario 1: Same sensor name on different devices (allowed) +# Scenario 1: Different sensor names on different devices (allowed) sensor: - platform: template - name: Temperature + name: Temperature Controller 1 device_id: controller_1 lambda: return 21.0; update_interval: 0.1s - platform: template - name: Temperature + name: Temperature Controller 2 device_id: controller_2 lambda: return 22.0; update_interval: 0.1s - platform: template - name: Temperature + name: Temperature Controller 3 device_id: controller_3 lambda: return 23.0; update_interval: 0.1s # Main device sensor (no device_id) - platform: template - name: Temperature + name: Temperature Main lambda: return 20.0; update_interval: 0.1s @@ -47,20 +47,20 @@ sensor: lambda: return 60.0; update_interval: 0.1s -# Scenario 2: Same binary sensor name on different devices (allowed) +# Scenario 2: Different binary sensor names on different devices binary_sensor: - platform: template - name: Status + name: Status Controller 1 device_id: controller_1 lambda: return true; - platform: template - name: Status + name: Status Controller 2 device_id: controller_2 lambda: return false; - platform: template - name: Status + name: Status Main lambda: return true; # Main device # Different platform can have same name as sensor @@ -68,43 +68,43 @@ binary_sensor: name: Temperature lambda: return true; -# Scenario 3: Same text sensor name on different devices +# Scenario 3: Different text sensor names on different devices text_sensor: - platform: template - name: Device Info + name: Device Info Controller 1 device_id: controller_1 lambda: return {"Controller 1 Active"}; update_interval: 0.1s - platform: template - name: Device Info + name: Device Info Controller 2 device_id: controller_2 lambda: return {"Controller 2 Active"}; update_interval: 0.1s - platform: template - name: Device Info + name: Device Info Main lambda: return {"Main Device Active"}; update_interval: 0.1s -# Scenario 4: Same switch name on different devices +# Scenario 4: Different switch names on different devices switch: - platform: template - name: Power + name: Power Controller 1 device_id: controller_1 lambda: return false; turn_on_action: [] turn_off_action: [] - platform: template - name: Power + name: Power Controller 2 device_id: controller_2 lambda: return true; turn_on_action: [] turn_off_action: [] - platform: template - name: Power + name: Power Controller 3 device_id: controller_3 lambda: return false; turn_on_action: [] @@ -117,26 +117,54 @@ switch: turn_on_action: [] turn_off_action: [] -# Scenario 5: Empty names on different devices (should use device name) +# Scenario 5: Buttons with unique names button: - platform: template - name: "" + name: "Reset Controller 1" device_id: controller_1 on_press: [] - platform: template - name: "" + name: "Reset Controller 2" device_id: controller_2 on_press: [] - platform: template - name: "" + name: "Reset Main" on_press: [] # Main device -# Scenario 6: Special characters in names +# Scenario 6: Empty names (should use device names) +select: + - platform: template + name: "" + device_id: controller_1 + options: + - "Option 1" + - "Option 2" + lambda: return {"Option 1"}; + set_action: [] + + - platform: template + name: "" + device_id: controller_2 + options: + - "Option 1" + - "Option 2" + lambda: return {"Option 1"}; + set_action: [] + + - platform: template + name: "" # Main device + options: + - "Option 1" + - "Option 2" + lambda: return {"Option 1"}; + set_action: [] + +# Scenario 7: Special characters in names - now with unique names number: - platform: template - name: "Temperature Setpoint!" + name: "Temperature Setpoint! Controller 1" device_id: controller_1 min_value: 10.0 max_value: 30.0 @@ -145,7 +173,7 @@ number: set_action: [] - platform: template - name: "Temperature Setpoint!" + name: "Temperature Setpoint! Controller 2" device_id: controller_2 min_value: 10.0 max_value: 30.0 diff --git a/tests/integration/test_duplicate_entities.py b/tests/integration/test_duplicate_entities.py index 99968204d4..b7ee8dd478 100644 --- a/tests/integration/test_duplicate_entities.py +++ b/tests/integration/test_duplicate_entities.py @@ -11,12 +11,12 @@ from .types import APIClientConnectedFactory, RunCompiledFunction @pytest.mark.asyncio -async def test_duplicate_entities_on_different_devices( +async def test_duplicate_entities_not_allowed_on_different_devices( yaml_config: str, run_compiled: RunCompiledFunction, api_client_connected: APIClientConnectedFactory, ) -> None: - """Test that duplicate entity names are allowed on different devices.""" + """Test that duplicate entity names are NOT allowed on different devices.""" async with run_compiled(yaml_config), api_client_connected() as client: # Get device info device_info = await client.device_info() @@ -52,42 +52,46 @@ async def test_duplicate_entities_on_different_devices( switches = [e for e in all_entities if e.__class__.__name__ == "SwitchInfo"] buttons = [e for e in all_entities if e.__class__.__name__ == "ButtonInfo"] numbers = [e for e in all_entities if e.__class__.__name__ == "NumberInfo"] + selects = [e for e in all_entities if e.__class__.__name__ == "SelectInfo"] - # Scenario 1: Check sensors with same "Temperature" name on different devices - temp_sensors = [s for s in sensors if s.name == "Temperature"] + # Scenario 1: Check that temperature sensors have unique names per device + temp_sensors = [s for s in sensors if "Temperature" in s.name] assert len(temp_sensors) == 4, ( f"Expected exactly 4 temperature sensors, got {len(temp_sensors)}" ) - # Verify each sensor is on a different device - temp_device_ids = set() + # Verify each sensor has a unique name + temp_names = set() temp_object_ids = set() for sensor in temp_sensors: - temp_device_ids.add(sensor.device_id) + temp_names.add(sensor.name) temp_object_ids.add(sensor.object_id) - # All should have object_id "temperature" (no suffix) - assert sensor.object_id == "temperature", ( - f"Expected object_id 'temperature', got '{sensor.object_id}'" - ) - - # Should have 4 different device IDs (including None for main device) - assert len(temp_device_ids) == 4, ( - f"Temperature sensors should be on different devices, got {temp_device_ids}" + # Should have 4 unique names + assert len(temp_names) == 4, ( + f"Temperature sensors should have unique names, got {temp_names}" ) - # Scenario 2: Check binary sensors "Status" on different devices - status_binary = [b for b in binary_sensors if b.name == "Status"] + # Object IDs should also be unique + assert len(temp_object_ids) == 4, ( + f"Temperature sensors should have unique object_ids, got {temp_object_ids}" + ) + + # Scenario 2: Check binary sensors have unique names + status_binary = [b for b in binary_sensors if "Status" in b.name] assert len(status_binary) == 3, ( f"Expected exactly 3 status binary sensors, got {len(status_binary)}" ) - # All should have object_id "status" + # All should have unique object_ids + status_names = set() for binary in status_binary: - assert binary.object_id == "status", ( - f"Expected object_id 'status', got '{binary.object_id}'" - ) + status_names.add(binary.name) + + assert len(status_names) == 3, ( + f"Status binary sensors should have unique names, got {status_names}" + ) # Scenario 3: Check that sensor and binary_sensor can have same name temp_binary = [b for b in binary_sensors if b.name == "Temperature"] @@ -96,62 +100,86 @@ async def test_duplicate_entities_on_different_devices( ) assert temp_binary[0].object_id == "temperature" - # Scenario 4: Check text sensors "Device Info" on different devices - info_text = [t for t in text_sensors if t.name == "Device Info"] + # Scenario 4: Check text sensors have unique names + info_text = [t for t in text_sensors if "Device Info" in t.name] assert len(info_text) == 3, ( f"Expected exactly 3 device info text sensors, got {len(info_text)}" ) - # All should have object_id "device_info" + # All should have unique names and object_ids + info_names = set() for text in info_text: - assert text.object_id == "device_info", ( - f"Expected object_id 'device_info', got '{text.object_id}'" - ) + info_names.add(text.name) - # Scenario 5: Check switches "Power" on different devices - power_switches = [s for s in switches if s.name == "Power"] - assert len(power_switches) == 3, ( - f"Expected exactly 3 power switches, got {len(power_switches)}" + assert len(info_names) == 3, ( + f"Device info text sensors should have unique names, got {info_names}" ) - # All should have object_id "power" - for switch in power_switches: - assert switch.object_id == "power", ( - f"Expected object_id 'power', got '{switch.object_id}'" - ) + # Scenario 5: Check switches have unique names + power_switches = [s for s in switches if "Power" in s.name] + assert len(power_switches) == 4, ( + f"Expected exactly 4 power switches, got {len(power_switches)}" + ) - # Scenario 6: Check empty name buttons (should use device name) - empty_buttons = [b for b in buttons if b.name == ""] - assert len(empty_buttons) == 3, ( - f"Expected exactly 3 empty name buttons, got {len(empty_buttons)}" + # All should have unique names + power_names = set() + for switch in power_switches: + power_names.add(switch.name) + + assert len(power_names) == 4, ( + f"Power switches should have unique names, got {power_names}" + ) + + # Scenario 6: Check reset buttons have unique names + reset_buttons = [b for b in buttons if "Reset" in b.name] + assert len(reset_buttons) == 3, ( + f"Expected exactly 3 reset buttons, got {len(reset_buttons)}" + ) + + # All should have unique names + reset_names = set() + for button in reset_buttons: + reset_names.add(button.name) + + assert len(reset_names) == 3, ( + f"Reset buttons should have unique names, got {reset_names}" + ) + + # Scenario 7: Check empty name selects (should use device names) + empty_selects = [s for s in selects if s.name == ""] + assert len(empty_selects) == 3, ( + f"Expected exactly 3 empty name selects, got {len(empty_selects)}" ) # Group by device - c1_buttons = [b for b in empty_buttons if b.device_id == controller_1.device_id] - c2_buttons = [b for b in empty_buttons if b.device_id == controller_2.device_id] + c1_selects = [s for s in empty_selects if s.device_id == controller_1.device_id] + c2_selects = [s for s in empty_selects if s.device_id == controller_2.device_id] # For main device, device_id is 0 - main_buttons = [b for b in empty_buttons if b.device_id == 0] + main_selects = [s for s in empty_selects if s.device_id == 0] - # Check object IDs for empty name entities - assert len(c1_buttons) == 1 and c1_buttons[0].object_id == "controller_1" - assert len(c2_buttons) == 1 and c2_buttons[0].object_id == "controller_2" + # Check object IDs for empty name entities - they should use device names + assert len(c1_selects) == 1 and c1_selects[0].object_id == "controller_1" + assert len(c2_selects) == 1 and c2_selects[0].object_id == "controller_2" assert ( - len(main_buttons) == 1 - and main_buttons[0].object_id == "duplicate-entities-test" + len(main_selects) == 1 + and main_selects[0].object_id == "duplicate-entities-test" ) - # Scenario 7: Check special characters in number names - temp_numbers = [n for n in numbers if n.name == "Temperature Setpoint!"] + # Scenario 8: Check special characters in number names - now unique + temp_numbers = [n for n in numbers if "Temperature Setpoint!" in n.name] assert len(temp_numbers) == 2, ( f"Expected exactly 2 temperature setpoint numbers, got {len(temp_numbers)}" ) - # Special characters should be sanitized to _ in object_id + # Should have unique names + setpoint_names = set() for number in temp_numbers: - assert number.object_id == "temperature_setpoint_", ( - f"Expected object_id 'temperature_setpoint_', got '{number.object_id}'" - ) + setpoint_names.add(number.name) + + assert len(setpoint_names) == 2, ( + f"Temperature setpoint numbers should have unique names, got {setpoint_names}" + ) # Verify we can get states for all entities (ensures they're functional) loop = asyncio.get_running_loop() @@ -164,6 +192,7 @@ async def test_duplicate_entities_on_different_devices( + len(switches) + len(buttons) + len(numbers) + + len(selects) ) def on_state(state) -> None: diff --git a/tests/unit_tests/core/test_entity_helpers.py b/tests/unit_tests/core/test_entity_helpers.py index e166eeedee..0dcdd84507 100644 --- a/tests/unit_tests/core/test_entity_helpers.py +++ b/tests/unit_tests/core/test_entity_helpers.py @@ -505,13 +505,13 @@ def test_entity_duplicate_validator() -> None: config1 = {CONF_NAME: "Temperature"} validated1 = validator(config1) assert validated1 == config1 - assert ("", "sensor", "temperature") in CORE.unique_ids + assert ("sensor", "temperature") in CORE.unique_ids # Second entity with different name should pass config2 = {CONF_NAME: "Humidity"} validated2 = validator(config2) assert validated2 == config2 - assert ("", "sensor", "humidity") in CORE.unique_ids + assert ("sensor", "humidity") in CORE.unique_ids # Duplicate entity should fail config3 = {CONF_NAME: "Temperature"} @@ -535,24 +535,36 @@ def test_entity_duplicate_validator_with_devices() -> None: device1 = ID("device1", type="Device") device2 = ID("device2", type="Device") - # Same name on different devices should pass + # First entity on device1 should pass config1 = {CONF_NAME: "Temperature", CONF_DEVICE_ID: device1} validated1 = validator(config1) assert validated1 == config1 - assert ("device1", "sensor", "temperature") in CORE.unique_ids + assert ("sensor", "temperature") in CORE.unique_ids + # Same name on different device should now fail config2 = {CONF_NAME: "Temperature", CONF_DEVICE_ID: device2} - validated2 = validator(config2) - assert validated2 == config2 - assert ("device2", "sensor", "temperature") in CORE.unique_ids - - # Duplicate on same device should fail - config3 = {CONF_NAME: "Temperature", CONF_DEVICE_ID: device1} with pytest.raises( Invalid, - match=r"Duplicate sensor entity with name 'Temperature' found on device 'device1'", + match=r"Duplicate sensor entity with name 'Temperature' found. Each entity must have a unique name within its platform across all devices.", ): - validator(config3) + validator(config2) + + # Different name on device2 should pass + config3 = {CONF_NAME: "Humidity", CONF_DEVICE_ID: device2} + validated3 = validator(config3) + assert validated3 == config3 + assert ("sensor", "humidity") in CORE.unique_ids + + # Empty names should use device names and be allowed + config4 = {CONF_NAME: "", CONF_DEVICE_ID: device1} + validated4 = validator(config4) + assert validated4 == config4 + assert ("sensor", "device1") in CORE.unique_ids + + config5 = {CONF_NAME: "", CONF_DEVICE_ID: device2} + validated5 = validator(config5) + assert validated5 == config5 + assert ("sensor", "device2") in CORE.unique_ids def test_duplicate_entity_yaml_validation( @@ -576,10 +588,10 @@ def test_duplicate_entity_with_devices_yaml_validation( ) assert result is None - # Check for the duplicate entity error message with device + # Check for the duplicate entity error message captured = capsys.readouterr() assert ( - "Duplicate sensor entity with name 'Temperature' found on device 'device1'" + "Duplicate sensor entity with name 'Temperature' found. Each entity must have a unique name within its platform across all devices." in captured.out )