Add ability to have same entity names on different sub devices (#9355)

Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
This commit is contained in:
J. Nick Koston 2025-07-15 23:34:51 -10:00 committed by GitHub
parent b15a09e8bc
commit e40b45cab1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 330 additions and 185 deletions

View File

@ -42,18 +42,37 @@ static const char *const TAG = "api.connection";
static const int CAMERA_STOP_STREAM = 5000; static const int CAMERA_STOP_STREAM = 5000;
#endif #endif
// Helper macro for entity command handlers - gets entity by key, returns if not found, and creates call object #ifdef USE_DEVICES
// Helper macro for entity command handlers - gets entity by key and device_id, returns if not found, and creates call
// object
#define ENTITY_COMMAND_MAKE_CALL(entity_type, entity_var, getter_name) \
entity_type *entity_var = App.get_##getter_name##_by_key(msg.key, msg.device_id); \
if ((entity_var) == nullptr) \
return; \
auto call = (entity_var)->make_call();
// Helper macro for entity command handlers that don't use make_call() - gets entity by key and device_id and returns if
// not found
#define ENTITY_COMMAND_GET(entity_type, entity_var, getter_name) \
entity_type *entity_var = App.get_##getter_name##_by_key(msg.key, msg.device_id); \
if ((entity_var) == nullptr) \
return;
#else // No device support, use simpler macros
// Helper macro for entity command handlers - gets entity by key, returns if not found, and creates call
// object
#define ENTITY_COMMAND_MAKE_CALL(entity_type, entity_var, getter_name) \ #define ENTITY_COMMAND_MAKE_CALL(entity_type, entity_var, getter_name) \
entity_type *entity_var = App.get_##getter_name##_by_key(msg.key); \ entity_type *entity_var = App.get_##getter_name##_by_key(msg.key); \
if ((entity_var) == nullptr) \ if ((entity_var) == nullptr) \
return; \ return; \
auto call = (entity_var)->make_call(); auto call = (entity_var)->make_call();
// Helper macro for entity command handlers that don't use make_call() - gets entity by key and returns if not found // Helper macro for entity command handlers that don't use make_call() - gets entity by key and returns if
// not found
#define ENTITY_COMMAND_GET(entity_type, entity_var, getter_name) \ #define ENTITY_COMMAND_GET(entity_type, entity_var, getter_name) \
entity_type *entity_var = App.get_##getter_name##_by_key(msg.key); \ entity_type *entity_var = App.get_##getter_name##_by_key(msg.key); \
if ((entity_var) == nullptr) \ if ((entity_var) == nullptr) \
return; return;
#endif // USE_DEVICES
APIConnection::APIConnection(std::unique_ptr<socket::Socket> sock, APIServer *parent) APIConnection::APIConnection(std::unique_ptr<socket::Socket> sock, APIServer *parent)
: parent_(parent), initial_state_iterator_(this), list_entities_iterator_(this) { : parent_(parent), initial_state_iterator_(this), list_entities_iterator_(this) {

View File

@ -368,8 +368,19 @@ class Application {
uint8_t get_app_state() const { return this->app_state_; } uint8_t get_app_state() const { return this->app_state_; }
// Helper macro for entity getter method declarations - reduces code duplication // Helper macro for entity getter method declarations
// When USE_DEVICE_ID is enabled in the future, this can be conditionally compiled to add device_id parameter #ifdef USE_DEVICES
#define GET_ENTITY_METHOD(entity_type, entity_name, entities_member) \
entity_type *get_##entity_name##_by_key(uint32_t key, uint32_t device_id, bool include_internal = false) { \
for (auto *obj : this->entities_member##_) { \
if (obj->get_object_id_hash() == key && obj->get_device_id() == device_id && \
(include_internal || !obj->is_internal())) \
return obj; \
} \
return nullptr; \
}
const std::vector<Device *> &get_devices() { return this->devices_; }
#else
#define GET_ENTITY_METHOD(entity_type, entity_name, entities_member) \ #define GET_ENTITY_METHOD(entity_type, entity_name, entities_member) \
entity_type *get_##entity_name##_by_key(uint32_t key, bool include_internal = false) { \ entity_type *get_##entity_name##_by_key(uint32_t key, bool include_internal = false) { \
for (auto *obj : this->entities_member##_) { \ for (auto *obj : this->entities_member##_) { \
@ -378,10 +389,7 @@ class Application {
} \ } \
return nullptr; \ return nullptr; \
} }
#endif // USE_DEVICES
#ifdef USE_DEVICES
const std::vector<Device *> &get_devices() { return this->devices_; }
#endif
#ifdef USE_AREAS #ifdef USE_AREAS
const std::vector<Area *> &get_areas() { return this->areas_; } const std::vector<Area *> &get_areas() { return this->areas_; }
#endif #endif

View File

@ -198,9 +198,12 @@ def entity_duplicate_validator(platform: str) -> Callable[[ConfigType], ConfigTy
# Get device name if entity is on a sub-device # Get device name if entity is on a sub-device
device_name = None device_name = None
device_id = "" # Empty string for main device
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]
device_name = device_id_obj.id device_name = device_id_obj.id
# Use the device ID string directly for uniqueness
device_id = device_id_obj.id
# Calculate what object_id will actually be used # Calculate what object_id will actually be used
# This handles empty names correctly by using device/friendly names # This handles empty names correctly by using device/friendly names
@ -209,11 +212,12 @@ def entity_duplicate_validator(platform: str) -> Callable[[ConfigType], ConfigTy
) )
# Check for duplicates # Check for duplicates
unique_key = (platform, name_key) unique_key = (device_id, 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. " f"Duplicate {platform} entity with name '{entity_name}' found{device_prefix}. "
f"Each entity must have a unique name within its platform across all devices." f"Each entity on a device must have a unique name within its platform."
) )
# Add to tracking set # Add to tracking set

View File

@ -54,3 +54,45 @@ sensor:
device_id: smart_switch_device device_id: smart_switch_device
lambda: return 4.0; lambda: return 4.0;
update_interval: 0.1s update_interval: 0.1s
# Switches with the same name on different devices to test device_id lookup
switch:
# Switch with no device_id (defaults to 0)
- platform: template
name: Test Switch
id: test_switch_main
optimistic: true
turn_on_action:
- logger.log: "Turning on Test Switch on Main Device (no device_id)"
turn_off_action:
- logger.log: "Turning off Test Switch on Main Device (no device_id)"
- platform: template
name: Test Switch
device_id: light_controller_device
id: test_switch_light_controller
optimistic: true
turn_on_action:
- logger.log: "Turning on Test Switch on Light Controller"
turn_off_action:
- logger.log: "Turning off Test Switch on Light Controller"
- platform: template
name: Test Switch
device_id: temp_sensor_device
id: test_switch_temp_sensor
optimistic: true
turn_on_action:
- logger.log: "Turning on Test Switch on Temperature Sensor"
turn_off_action:
- logger.log: "Turning off Test Switch on Temperature Sensor"
- platform: template
name: Test Switch
device_id: motion_detector_device
id: test_switch_motion_detector
optimistic: true
turn_on_action:
- logger.log: "Turning on Test Switch on Motion Detector"
turn_off_action:
- logger.log: "Turning off Test Switch on Motion Detector"

View File

@ -1,6 +1,6 @@
esphome: esphome:
name: duplicate-entities-test name: duplicate-entities-test
# Define devices to test multi-device unique name validation # Define devices to test multi-device duplicate handling
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 NOT allowed on different devices # Test that duplicate entity names are allowed on different devices
# Scenario 1: Different sensor names on different devices (allowed) # Scenario 1: Same sensor name on different devices (allowed)
sensor: sensor:
- platform: template - platform: template
name: Temperature Controller 1 name: Temperature
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 Controller 2 name: Temperature
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 Controller 3 name: Temperature
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 Main name: Temperature
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: Different binary sensor names on different devices # Scenario 2: Same binary sensor name on different devices (allowed)
binary_sensor: binary_sensor:
- platform: template - platform: template
name: Status Controller 1 name: Status
device_id: controller_1 device_id: controller_1
lambda: return true; lambda: return true;
- platform: template - platform: template
name: Status Controller 2 name: Status
device_id: controller_2 device_id: controller_2
lambda: return false; lambda: return false;
- platform: template - platform: template
name: Status Main name: Status
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: Different text sensor names on different devices # Scenario 3: Same text sensor name on different devices
text_sensor: text_sensor:
- platform: template - platform: template
name: Device Info Controller 1 name: Device Info
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 Controller 2 name: Device Info
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 Main name: Device Info
lambda: return {"Main Device Active"}; lambda: return {"Main Device Active"};
update_interval: 0.1s update_interval: 0.1s
# Scenario 4: Different switch names on different devices # Scenario 4: Same switch name on different devices
switch: switch:
- platform: template - platform: template
name: Power Controller 1 name: Power
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 Controller 2 name: Power
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 Controller 3 name: Power
device_id: controller_3 device_id: controller_3
lambda: return false; lambda: return false;
turn_on_action: [] turn_on_action: []
@ -117,54 +117,26 @@ switch:
turn_on_action: [] turn_on_action: []
turn_off_action: [] turn_off_action: []
# Scenario 5: Buttons with unique names # Scenario 5: Empty names on different devices (should use device name)
button: button:
- platform: template - platform: template
name: "Reset Controller 1" name: ""
device_id: controller_1 device_id: controller_1
on_press: [] on_press: []
- platform: template - platform: template
name: "Reset Controller 2" name: ""
device_id: controller_2 device_id: controller_2
on_press: [] on_press: []
- platform: template - platform: template
name: "Reset Main" name: ""
on_press: [] # Main device on_press: [] # Main device
# Scenario 6: Empty names (should use device names) # Scenario 6: Special characters in 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! Controller 1" name: "Temperature Setpoint!"
device_id: controller_1 device_id: controller_1
min_value: 10.0 min_value: 10.0
max_value: 30.0 max_value: 30.0
@ -173,7 +145,7 @@ number:
set_action: [] set_action: []
- platform: template - platform: template
name: "Temperature Setpoint! Controller 2" name: "Temperature Setpoint!"
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

@ -4,7 +4,7 @@ from __future__ import annotations
import asyncio import asyncio
from aioesphomeapi import EntityState from aioesphomeapi import EntityState, SwitchInfo, SwitchState
import pytest import pytest
from .types import APIClientConnectedFactory, RunCompiledFunction from .types import APIClientConnectedFactory, RunCompiledFunction
@ -84,23 +84,45 @@ async def test_areas_and_devices(
# Subscribe to states to get sensor values # Subscribe to states to get sensor values
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
states: dict[int, EntityState] = {} states: dict[tuple[int, int], EntityState] = {}
states_future: asyncio.Future[bool] = loop.create_future() # Subscribe to all switch states
switch_state_futures: dict[
tuple[int, int], asyncio.Future[EntityState]
] = {} # (device_id, key) -> future
initial_states_received: set[tuple[int, int]] = set()
initial_states_future: asyncio.Future[bool] = loop.create_future()
def on_state(state: EntityState) -> None: def on_state(state: EntityState) -> None:
states[state.key] = state state_key = (state.device_id, state.key)
# Check if we have all expected sensor states states[state_key] = state
if len(states) >= 4 and not states_future.done():
states_future.set_result(True) initial_states_received.add(state_key)
# Check if we have all initial states
if (
len(initial_states_received) >= 8 # 8 entities expected
and not initial_states_future.done()
):
initial_states_future.set_result(True)
if not initial_states_future.done():
return
# Resolve the future for this switch if it exists
if (
state_key in switch_state_futures
and not switch_state_futures[state_key].done()
and isinstance(state, SwitchState)
):
switch_state_futures[state_key].set_result(state)
client.subscribe_states(on_state) client.subscribe_states(on_state)
# Wait for sensor states # Wait for sensor states
try: try:
await asyncio.wait_for(states_future, timeout=10.0) await asyncio.wait_for(initial_states_future, timeout=10.0)
except TimeoutError: except TimeoutError:
pytest.fail( pytest.fail(
f"Did not receive all sensor states within 10 seconds. " f"Did not receive all states within 10 seconds. "
f"Received {len(states)} states" f"Received {len(states)} states"
) )
@ -119,3 +141,121 @@ async def test_areas_and_devices(
f"{entity.name} has device_id {entity.device_id}, " f"{entity.name} has device_id {entity.device_id}, "
f"expected {expected_device_id}" f"expected {expected_device_id}"
) )
all_entities, _ = entities # Unpack the tuple
switch_entities = [e for e in all_entities if isinstance(e, SwitchInfo)]
# Find all switches named "Test Switch"
test_switches = [e for e in switch_entities if e.name == "Test Switch"]
assert len(test_switches) == 4, (
f"Expected 4 'Test Switch' entities, got {len(test_switches)}"
)
# Verify we have switches with different device_ids including one with 0 (main)
switch_device_ids = {s.device_id for s in test_switches}
assert len(switch_device_ids) == 4, (
"All Test Switch entities should have different device_ids"
)
assert 0 in switch_device_ids, (
"Should have a switch with device_id 0 (main device)"
)
# Wait for initial states to be received for all switches
await asyncio.wait_for(initial_states_future, timeout=2.0)
# Test controlling each switch specifically by device_id
for device_name, device in [
("Light Controller", light_controller),
("Temperature Sensor", temp_sensor),
("Motion Detector", motion_detector),
]:
# Find the switch for this specific device
device_switch = next(
(s for s in test_switches if s.device_id == device.device_id), None
)
assert device_switch is not None, f"No Test Switch found for {device_name}"
# Create future for this switch's state change
state_key = (device_switch.device_id, device_switch.key)
switch_state_futures[state_key] = loop.create_future()
# Turn on the switch with device_id
client.switch_command(
device_switch.key, True, device_id=device_switch.device_id
)
# Wait for state to change
await asyncio.wait_for(switch_state_futures[state_key], timeout=2.0)
# Verify the correct switch was turned on
assert states[state_key].state is True, f"{device_name} switch should be on"
# Create new future for turning off
switch_state_futures[state_key] = loop.create_future()
# Turn off the switch with device_id
client.switch_command(
device_switch.key, False, device_id=device_switch.device_id
)
# Wait for state to change
await asyncio.wait_for(switch_state_futures[state_key], timeout=2.0)
# Verify the correct switch was turned off
assert states[state_key].state is False, (
f"{device_name} switch should be off"
)
# Test that controlling a switch with device_id doesn't affect main switch
# Find the main switch (device_id = 0)
main_switch = next((s for s in test_switches if s.device_id == 0), None)
assert main_switch is not None, "No main switch (device_id=0) found"
# Find a switch with a device_id
device_switch = next(
(s for s in test_switches if s.device_id == light_controller.device_id),
None,
)
assert device_switch is not None, "No device switch found"
# Create futures for both switches
main_key = (main_switch.device_id, main_switch.key)
device_key = (device_switch.device_id, device_switch.key)
# Turn on the main switch first
switch_state_futures[main_key] = loop.create_future()
client.switch_command(main_switch.key, True, device_id=main_switch.device_id)
await asyncio.wait_for(switch_state_futures[main_key], timeout=2.0)
assert states[main_key].state is True, "Main switch should be on"
# Now turn on the device switch
switch_state_futures[device_key] = loop.create_future()
client.switch_command(
device_switch.key, True, device_id=device_switch.device_id
)
await asyncio.wait_for(switch_state_futures[device_key], timeout=2.0)
# Verify device switch is on and main switch is still on
assert states[device_key].state is True, "Device switch should be on"
assert states[main_key].state is True, (
"Main switch should still be on after turning on device switch"
)
# Turn off the device switch
switch_state_futures[device_key] = loop.create_future()
client.switch_command(
device_switch.key, False, device_id=device_switch.device_id
)
await asyncio.wait_for(switch_state_futures[device_key], timeout=2.0)
# Verify device switch is off and main switch is still on
assert states[device_key].state is False, "Device switch should be off"
assert states[main_key].state is True, (
"Main switch should still be on after turning off device switch"
)
# Clean up - turn off main switch
switch_state_futures[main_key] = loop.create_future()
client.switch_command(main_switch.key, False, device_id=main_switch.device_id)
await asyncio.wait_for(switch_state_futures[main_key], timeout=2.0)
assert states[main_key].state is False, "Main switch should be off"

View File

@ -11,12 +11,12 @@ from .types import APIClientConnectedFactory, RunCompiledFunction
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_duplicate_entities_not_allowed_on_different_devices( async def test_duplicate_entities_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 NOT allowed on different devices.""" """Test that duplicate entity names are 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,46 +52,42 @@ async def test_duplicate_entities_not_allowed_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 that temperature sensors have unique names per device # Scenario 1: Check sensors with same "Temperature" name on different devices
temp_sensors = [s for s in sensors if "Temperature" in s.name] temp_sensors = [s for s in sensors if s.name == "Temperature"]
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 has a unique name # Verify each sensor is on a different device
temp_names = set() temp_device_ids = set()
temp_object_ids = set() temp_object_ids = set()
for sensor in temp_sensors: for sensor in temp_sensors:
temp_names.add(sensor.name) temp_device_ids.add(sensor.device_id)
temp_object_ids.add(sensor.object_id) temp_object_ids.add(sensor.object_id)
# Should have 4 unique names # All should have object_id "temperature" (no suffix)
assert len(temp_names) == 4, ( assert sensor.object_id == "temperature", (
f"Temperature sensors should have unique names, got {temp_names}" 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}"
) )
# Object IDs should also be unique # Scenario 2: Check binary sensors "Status" on different devices
assert len(temp_object_ids) == 4, ( status_binary = [b for b in binary_sensors if b.name == "Status"]
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 unique object_ids # All should have object_id "status"
status_names = set()
for binary in status_binary: for binary in status_binary:
status_names.add(binary.name) assert binary.object_id == "status", (
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"]
@ -100,86 +96,62 @@ async def test_duplicate_entities_not_allowed_on_different_devices(
) )
assert temp_binary[0].object_id == "temperature" assert temp_binary[0].object_id == "temperature"
# Scenario 4: Check text sensors have unique names # Scenario 4: Check text sensors "Device Info" on different devices
info_text = [t for t in text_sensors if "Device Info" in t.name] info_text = [t for t in text_sensors if t.name == "Device Info"]
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 unique names and object_ids # All should have object_id "device_info"
info_names = set()
for text in info_text: for text in info_text:
info_names.add(text.name) assert text.object_id == "device_info", (
f"Expected object_id 'device_info', got '{text.object_id}'"
)
assert len(info_names) == 3, ( # Scenario 5: Check switches "Power" on different devices
f"Device info text sensors should have unique names, got {info_names}" 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)}"
) )
# Scenario 5: Check switches have unique names # All should have object_id "power"
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)}"
)
# All should have unique names
power_names = set()
for switch in power_switches: for switch in power_switches:
power_names.add(switch.name) assert switch.object_id == "power", (
f"Expected object_id 'power', got '{switch.object_id}'"
)
assert len(power_names) == 4, ( # Scenario 6: Check empty name buttons (should use device name)
f"Power switches should have unique names, got {power_names}" 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)}"
# 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_selects = [s for s in empty_selects if s.device_id == controller_1.device_id] c1_buttons = [b for b in empty_buttons if b.device_id == controller_1.device_id]
c2_selects = [s for s in empty_selects if s.device_id == controller_2.device_id] c2_buttons = [b for b in empty_buttons if b.device_id == controller_2.device_id]
# For main device, device_id is 0 # For main device, device_id is 0
main_selects = [s for s in empty_selects if s.device_id == 0] main_buttons = [b for b in empty_buttons if b.device_id == 0]
# Check object IDs for empty name entities - they should use device names # Check object IDs for empty name entities
assert len(c1_selects) == 1 and c1_selects[0].object_id == "controller_1" assert len(c1_buttons) == 1 and c1_buttons[0].object_id == "controller_1"
assert len(c2_selects) == 1 and c2_selects[0].object_id == "controller_2" assert len(c2_buttons) == 1 and c2_buttons[0].object_id == "controller_2"
assert ( assert (
len(main_selects) == 1 len(main_buttons) == 1
and main_selects[0].object_id == "duplicate-entities-test" and main_buttons[0].object_id == "duplicate-entities-test"
) )
# Scenario 8: Check special characters in number names - now unique # Scenario 7: Check special characters in number names
temp_numbers = [n for n in numbers if "Temperature Setpoint!" in n.name] temp_numbers = [n for n in numbers if n.name == "Temperature Setpoint!"]
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)}"
) )
# Should have unique names # Special characters should be sanitized to _ in object_id
setpoint_names = set()
for number in temp_numbers: for number in temp_numbers:
setpoint_names.add(number.name) assert number.object_id == "temperature_setpoint_", (
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()
@ -192,7 +164,6 @@ async def test_duplicate_entities_not_allowed_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

@ -510,13 +510,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,36 +535,24 @@ 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")
# First entity on device1 should pass # Same name on different devices 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 ("sensor", "temperature") in CORE.unique_ids assert ("device1", "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. Each entity must have a unique name within its platform across all devices.", match=r"Duplicate sensor entity with name 'Temperature' found on device 'device1'",
): ):
validator(config2) validator(config3)
# 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(
@ -588,10 +576,10 @@ def test_duplicate_entity_with_devices_yaml_validation(
) )
assert result is None assert result is None
# Check for the duplicate entity error message # Check for the duplicate entity error message with device
captured = capsys.readouterr() captured = capsys.readouterr()
assert ( assert (
"Duplicate sensor entity with name 'Temperature' found. Each entity must have a unique name within its platform across all devices." "Duplicate sensor entity with name 'Temperature' found on device 'device1'"
in captured.out in captured.out
) )
@ -616,21 +604,22 @@ def test_entity_duplicate_validator_internal_entities() -> 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 # New format includes device_id (empty string for main device)
assert ("", "sensor", "temperature") in CORE.unique_ids
# Internal entity with same name should pass (not added to unique_ids) # Internal entity with same name should pass (not added to unique_ids)
config2 = {CONF_NAME: "Temperature", CONF_INTERNAL: True} config2 = {CONF_NAME: "Temperature", CONF_INTERNAL: True}
validated2 = validator(config2) validated2 = validator(config2)
assert validated2 == config2 assert validated2 == config2
# Internal entity should not be added to unique_ids # Internal entity should not be added to unique_ids
assert len([k for k in CORE.unique_ids if k == ("sensor", "temperature")]) == 1 assert len([k for k in CORE.unique_ids if k == ("", "sensor", "temperature")]) == 1
# Another internal entity with same name should also pass # Another internal entity with same name should also pass
config3 = {CONF_NAME: "Temperature", CONF_INTERNAL: True} config3 = {CONF_NAME: "Temperature", CONF_INTERNAL: True}
validated3 = validator(config3) validated3 = validator(config3)
assert validated3 == config3 assert validated3 == config3
# Still only one entry in unique_ids (from the non-internal entity) # Still only one entry in unique_ids (from the non-internal entity)
assert len([k for k in CORE.unique_ids if k == ("sensor", "temperature")]) == 1 assert len([k for k in CORE.unique_ids if k == ("", "sensor", "temperature")]) == 1
# Non-internal entity with same name should fail # Non-internal entity with same name should fail
config4 = {CONF_NAME: "Temperature"} config4 = {CONF_NAME: "Temperature"}