Fix entity hash collisions by enforcing unique names across devices per platform (#9276)

This commit is contained in:
J. Nick Koston 2025-06-30 21:38:19 -05:00 committed by GitHub
parent db7a420e54
commit 9a0ba1657e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 175 additions and 104 deletions

View File

@ -184,25 +184,27 @@ def entity_duplicate_validator(platform: str) -> Callable[[ConfigType], ConfigTy
# No name to validate # No name to validate
return config return config
# Get the entity name and device info # Get the entity name
entity_name = config[CONF_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: if CONF_DEVICE_ID in config:
device_id_obj = config[CONF_DEVICE_ID] device_id_obj = config[CONF_DEVICE_ID]
# Use the device ID string directly for uniqueness device_name = device_id_obj.id
device_id = device_id_obj.id
# For duplicate detection, just use the sanitized name # Calculate what object_id will actually be used
name_key = sanitize(snake_case(entity_name)) # 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 # Check for duplicates
unique_key = (device_id, platform, name_key) unique_key = (platform, name_key)
if unique_key in CORE.unique_ids: if unique_key in CORE.unique_ids:
device_prefix = f" on device '{device_id}'" if device_id else ""
raise cv.Invalid( raise cv.Invalid(
f"Duplicate {platform} entity with name '{entity_name}' found{device_prefix}. " f"Duplicate {platform} entity with name '{entity_name}' found. "
f"Each entity on a device must have a unique name within its platform." f"Each entity must have a unique name within its platform across all devices."
) )
# Add to tracking set # Add to tracking set

View File

@ -1,6 +1,6 @@
esphome: esphome:
name: duplicate-entities-test name: duplicate-entities-test
# Define devices to test multi-device duplicate handling # Define devices to test multi-device unique name validation
devices: devices:
- id: controller_1 - id: controller_1
name: Controller 1 name: Controller 1
@ -13,31 +13,31 @@ host:
api: # Port will be automatically injected api: # Port will be automatically injected
logger: 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: sensor:
- platform: template - platform: template
name: Temperature name: Temperature Controller 1
device_id: controller_1 device_id: controller_1
lambda: return 21.0; lambda: return 21.0;
update_interval: 0.1s update_interval: 0.1s
- platform: template - platform: template
name: Temperature name: Temperature Controller 2
device_id: controller_2 device_id: controller_2
lambda: return 22.0; lambda: return 22.0;
update_interval: 0.1s update_interval: 0.1s
- platform: template - platform: template
name: Temperature name: Temperature Controller 3
device_id: controller_3 device_id: controller_3
lambda: return 23.0; lambda: return 23.0;
update_interval: 0.1s update_interval: 0.1s
# Main device sensor (no device_id) # Main device sensor (no device_id)
- platform: template - platform: template
name: Temperature name: Temperature Main
lambda: return 20.0; lambda: return 20.0;
update_interval: 0.1s update_interval: 0.1s
@ -47,20 +47,20 @@ sensor:
lambda: return 60.0; lambda: return 60.0;
update_interval: 0.1s 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: binary_sensor:
- platform: template - platform: template
name: Status name: Status Controller 1
device_id: controller_1 device_id: controller_1
lambda: return true; lambda: return true;
- platform: template - platform: template
name: Status name: Status Controller 2
device_id: controller_2 device_id: controller_2
lambda: return false; lambda: return false;
- platform: template - platform: template
name: Status name: Status Main
lambda: return true; # Main device lambda: return true; # Main device
# Different platform can have same name as sensor # Different platform can have same name as sensor
@ -68,43 +68,43 @@ binary_sensor:
name: Temperature name: Temperature
lambda: return true; lambda: return true;
# Scenario 3: Same text sensor name on different devices # Scenario 3: Different text sensor names on different devices
text_sensor: text_sensor:
- platform: template - platform: template
name: Device Info name: Device Info Controller 1
device_id: controller_1 device_id: controller_1
lambda: return {"Controller 1 Active"}; lambda: return {"Controller 1 Active"};
update_interval: 0.1s update_interval: 0.1s
- platform: template - platform: template
name: Device Info name: Device Info Controller 2
device_id: controller_2 device_id: controller_2
lambda: return {"Controller 2 Active"}; lambda: return {"Controller 2 Active"};
update_interval: 0.1s update_interval: 0.1s
- platform: template - platform: template
name: Device Info name: Device Info Main
lambda: return {"Main Device Active"}; lambda: return {"Main Device Active"};
update_interval: 0.1s update_interval: 0.1s
# Scenario 4: Same switch name on different devices # Scenario 4: Different switch names on different devices
switch: switch:
- platform: template - platform: template
name: Power name: Power Controller 1
device_id: controller_1 device_id: controller_1
lambda: return false; lambda: return false;
turn_on_action: [] turn_on_action: []
turn_off_action: [] turn_off_action: []
- platform: template - platform: template
name: Power name: Power Controller 2
device_id: controller_2 device_id: controller_2
lambda: return true; lambda: return true;
turn_on_action: [] turn_on_action: []
turn_off_action: [] turn_off_action: []
- platform: template - platform: template
name: Power name: Power Controller 3
device_id: controller_3 device_id: controller_3
lambda: return false; lambda: return false;
turn_on_action: [] turn_on_action: []
@ -117,26 +117,54 @@ switch:
turn_on_action: [] turn_on_action: []
turn_off_action: [] turn_off_action: []
# Scenario 5: Empty names on different devices (should use device name) # Scenario 5: Buttons with unique names
button: button:
- platform: template - platform: template
name: "" name: "Reset Controller 1"
device_id: controller_1 device_id: controller_1
on_press: [] on_press: []
- platform: template - platform: template
name: "" name: "Reset Controller 2"
device_id: controller_2 device_id: controller_2
on_press: [] on_press: []
- platform: template - platform: template
name: "" name: "Reset Main"
on_press: [] # Main device 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: number:
- platform: template - platform: template
name: "Temperature Setpoint!" name: "Temperature Setpoint! Controller 1"
device_id: controller_1 device_id: controller_1
min_value: 10.0 min_value: 10.0
max_value: 30.0 max_value: 30.0
@ -145,7 +173,7 @@ number:
set_action: [] set_action: []
- platform: template - platform: template
name: "Temperature Setpoint!" name: "Temperature Setpoint! Controller 2"
device_id: controller_2 device_id: controller_2
min_value: 10.0 min_value: 10.0
max_value: 30.0 max_value: 30.0

View File

@ -11,12 +11,12 @@ from .types import APIClientConnectedFactory, RunCompiledFunction
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_duplicate_entities_on_different_devices( async def test_duplicate_entities_not_allowed_on_different_devices(
yaml_config: str, yaml_config: str,
run_compiled: RunCompiledFunction, run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory, api_client_connected: APIClientConnectedFactory,
) -> None: ) -> 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: async with run_compiled(yaml_config), api_client_connected() as client:
# Get device info # Get device info
device_info = await client.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"] switches = [e for e in all_entities if e.__class__.__name__ == "SwitchInfo"]
buttons = [e for e in all_entities if e.__class__.__name__ == "ButtonInfo"] buttons = [e for e in all_entities if e.__class__.__name__ == "ButtonInfo"]
numbers = [e for e in all_entities if e.__class__.__name__ == "NumberInfo"] 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 # Scenario 1: Check that temperature sensors have unique names per device
temp_sensors = [s for s in sensors if s.name == "Temperature"] temp_sensors = [s for s in sensors if "Temperature" in s.name]
assert len(temp_sensors) == 4, ( assert len(temp_sensors) == 4, (
f"Expected exactly 4 temperature sensors, got {len(temp_sensors)}" f"Expected exactly 4 temperature sensors, got {len(temp_sensors)}"
) )
# Verify each sensor is on a different device # Verify each sensor has a unique name
temp_device_ids = set() temp_names = set()
temp_object_ids = set() temp_object_ids = set()
for sensor in temp_sensors: for sensor in temp_sensors:
temp_device_ids.add(sensor.device_id) temp_names.add(sensor.name)
temp_object_ids.add(sensor.object_id) temp_object_ids.add(sensor.object_id)
# All should have object_id "temperature" (no suffix) # Should have 4 unique names
assert sensor.object_id == "temperature", ( assert len(temp_names) == 4, (
f"Expected object_id 'temperature', got '{sensor.object_id}'" f"Temperature sensors should have unique names, got {temp_names}"
)
# 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}"
) )
# Scenario 2: Check binary sensors "Status" on different devices # Object IDs should also be unique
status_binary = [b for b in binary_sensors if b.name == "Status"] 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, ( assert len(status_binary) == 3, (
f"Expected exactly 3 status binary sensors, got {len(status_binary)}" 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: for binary in status_binary:
assert binary.object_id == "status", ( status_names.add(binary.name)
f"Expected object_id 'status', got '{binary.object_id}'"
) 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 # Scenario 3: Check that sensor and binary_sensor can have same name
temp_binary = [b for b in binary_sensors if b.name == "Temperature"] 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" assert temp_binary[0].object_id == "temperature"
# Scenario 4: Check text sensors "Device Info" on different devices # Scenario 4: Check text sensors have unique names
info_text = [t for t in text_sensors if t.name == "Device Info"] info_text = [t for t in text_sensors if "Device Info" in t.name]
assert len(info_text) == 3, ( assert len(info_text) == 3, (
f"Expected exactly 3 device info text sensors, got {len(info_text)}" 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: for text in info_text:
assert text.object_id == "device_info", ( info_names.add(text.name)
f"Expected object_id 'device_info', got '{text.object_id}'"
)
# Scenario 5: Check switches "Power" on different devices assert len(info_names) == 3, (
power_switches = [s for s in switches if s.name == "Power"] f"Device info text sensors should have unique names, got {info_names}"
assert len(power_switches) == 3, (
f"Expected exactly 3 power switches, got {len(power_switches)}"
) )
# All should have object_id "power" # Scenario 5: Check switches have unique names
for switch in power_switches: power_switches = [s for s in switches if "Power" in s.name]
assert switch.object_id == "power", ( assert len(power_switches) == 4, (
f"Expected object_id 'power', got '{switch.object_id}'" f"Expected exactly 4 power switches, got {len(power_switches)}"
) )
# Scenario 6: Check empty name buttons (should use device name) # All should have unique names
empty_buttons = [b for b in buttons if b.name == ""] power_names = set()
assert len(empty_buttons) == 3, ( for switch in power_switches:
f"Expected exactly 3 empty name buttons, got {len(empty_buttons)}" 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 # Group by device
c1_buttons = [b for b in empty_buttons if b.device_id == controller_1.device_id] c1_selects = [s for s in empty_selects if s.device_id == controller_1.device_id]
c2_buttons = [b for b in empty_buttons if b.device_id == controller_2.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 # 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 # Check object IDs for empty name entities - they should use device names
assert len(c1_buttons) == 1 and c1_buttons[0].object_id == "controller_1" assert len(c1_selects) == 1 and c1_selects[0].object_id == "controller_1"
assert len(c2_buttons) == 1 and c2_buttons[0].object_id == "controller_2" assert len(c2_selects) == 1 and c2_selects[0].object_id == "controller_2"
assert ( assert (
len(main_buttons) == 1 len(main_selects) == 1
and main_buttons[0].object_id == "duplicate-entities-test" and main_selects[0].object_id == "duplicate-entities-test"
) )
# Scenario 7: Check special characters in number names # Scenario 8: Check special characters in number names - now unique
temp_numbers = [n for n in numbers if n.name == "Temperature Setpoint!"] temp_numbers = [n for n in numbers if "Temperature Setpoint!" in n.name]
assert len(temp_numbers) == 2, ( assert len(temp_numbers) == 2, (
f"Expected exactly 2 temperature setpoint numbers, got {len(temp_numbers)}" 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: for number in temp_numbers:
assert number.object_id == "temperature_setpoint_", ( setpoint_names.add(number.name)
f"Expected object_id 'temperature_setpoint_', got '{number.object_id}'"
) 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) # Verify we can get states for all entities (ensures they're functional)
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
@ -164,6 +192,7 @@ async def test_duplicate_entities_on_different_devices(
+ len(switches) + len(switches)
+ len(buttons) + len(buttons)
+ len(numbers) + len(numbers)
+ len(selects)
) )
def on_state(state) -> None: def on_state(state) -> None:

View File

@ -505,13 +505,13 @@ def test_entity_duplicate_validator() -> None:
config1 = {CONF_NAME: "Temperature"} config1 = {CONF_NAME: "Temperature"}
validated1 = validator(config1) validated1 = validator(config1)
assert validated1 == 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 # Second entity with different name should pass
config2 = {CONF_NAME: "Humidity"} config2 = {CONF_NAME: "Humidity"}
validated2 = validator(config2) validated2 = validator(config2)
assert validated2 == config2 assert validated2 == config2
assert ("", "sensor", "humidity") in CORE.unique_ids assert ("sensor", "humidity") in CORE.unique_ids
# Duplicate entity should fail # Duplicate entity should fail
config3 = {CONF_NAME: "Temperature"} config3 = {CONF_NAME: "Temperature"}
@ -535,24 +535,36 @@ def test_entity_duplicate_validator_with_devices() -> None:
device1 = ID("device1", type="Device") device1 = ID("device1", type="Device")
device2 = ID("device2", 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} config1 = {CONF_NAME: "Temperature", CONF_DEVICE_ID: device1}
validated1 = validator(config1) validated1 = validator(config1)
assert validated1 == 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} 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( with pytest.raises(
Invalid, 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( def test_duplicate_entity_yaml_validation(
@ -576,10 +588,10 @@ def test_duplicate_entity_with_devices_yaml_validation(
) )
assert result is None assert result is None
# Check for the duplicate entity error message with device # Check for the duplicate entity error message
captured = capsys.readouterr() captured = capsys.readouterr()
assert ( 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 in captured.out
) )