mirror of
				https://github.com/esphome/esphome.git
				synced 2025-10-30 06:33:51 +00:00 
			
		
		
		
	Fix entity hash collisions by enforcing unique names across devices per platform (#9276)
This commit is contained in:
		| @@ -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 | ||||
| @@ -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: | ||||
|   | ||||
| @@ -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 | ||||
|     ) | ||||
|  | ||||
|   | ||||
		Reference in New Issue
	
	Block a user