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-16 21:34:51 +12:00
committed by GitHub
co-authored by pre-commit-ci-lite[bot]
parent b15a09e8bc
commit e40b45cab1
8 changed files with 330 additions and 185 deletions
+21 -2
View File
@@ -42,18 +42,37 @@ static const char *const TAG = "api.connection";
static const int CAMERA_STOP_STREAM = 5000;
#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) \
entity_type *entity_var = App.get_##getter_name##_by_key(msg.key); \
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 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) \
entity_type *entity_var = App.get_##getter_name##_by_key(msg.key); \
if ((entity_var) == nullptr) \
return;
#endif // USE_DEVICES
APIConnection::APIConnection(std::unique_ptr<socket::Socket> sock, APIServer *parent)
: parent_(parent), initial_state_iterator_(this), list_entities_iterator_(this) {
+14 -6
View File
@@ -368,8 +368,19 @@ class Application {
uint8_t get_app_state() const { return this->app_state_; }
// Helper macro for entity getter method declarations - reduces code duplication
// When USE_DEVICE_ID is enabled in the future, this can be conditionally compiled to add device_id parameter
// Helper macro for entity getter method declarations
#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) \
entity_type *get_##entity_name##_by_key(uint32_t key, bool include_internal = false) { \
for (auto *obj : this->entities_member##_) { \
@@ -378,10 +389,7 @@ class Application {
} \
return nullptr; \
}
#ifdef USE_DEVICES
const std::vector<Device *> &get_devices() { return this->devices_; }
#endif
#endif // USE_DEVICES
#ifdef USE_AREAS
const std::vector<Area *> &get_areas() { return this->areas_; }
#endif
+7 -3
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
device_name = None
device_id = "" # Empty string for main device
if CONF_DEVICE_ID in config:
device_id_obj = config[CONF_DEVICE_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
# 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
unique_key = (platform, name_key)
unique_key = (device_id, 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. "
f"Each entity must have a unique name within its platform across all devices."
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."
)
# Add to tracking set
@@ -54,3 +54,45 @@ sensor:
device_id: smart_switch_device
lambda: return 4.0;
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"
@@ -1,6 +1,6 @@
esphome:
name: duplicate-entities-test
# Define devices to test multi-device unique name validation
# Define devices to test multi-device duplicate handling
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 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:
- platform: template
name: Temperature Controller 1
name: Temperature
device_id: controller_1
lambda: return 21.0;
update_interval: 0.1s
- platform: template
name: Temperature Controller 2
name: Temperature
device_id: controller_2
lambda: return 22.0;
update_interval: 0.1s
- platform: template
name: Temperature Controller 3
name: Temperature
device_id: controller_3
lambda: return 23.0;
update_interval: 0.1s
# Main device sensor (no device_id)
- platform: template
name: Temperature Main
name: Temperature
lambda: return 20.0;
update_interval: 0.1s
@@ -47,20 +47,20 @@ sensor:
lambda: return 60.0;
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:
- platform: template
name: Status Controller 1
name: Status
device_id: controller_1
lambda: return true;
- platform: template
name: Status Controller 2
name: Status
device_id: controller_2
lambda: return false;
- platform: template
name: Status Main
name: Status
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: Different text sensor names on different devices
# Scenario 3: Same text sensor name on different devices
text_sensor:
- platform: template
name: Device Info Controller 1
name: Device Info
device_id: controller_1
lambda: return {"Controller 1 Active"};
update_interval: 0.1s
- platform: template
name: Device Info Controller 2
name: Device Info
device_id: controller_2
lambda: return {"Controller 2 Active"};
update_interval: 0.1s
- platform: template
name: Device Info Main
name: Device Info
lambda: return {"Main Device Active"};
update_interval: 0.1s
# Scenario 4: Different switch names on different devices
# Scenario 4: Same switch name on different devices
switch:
- platform: template
name: Power Controller 1
name: Power
device_id: controller_1
lambda: return false;
turn_on_action: []
turn_off_action: []
- platform: template
name: Power Controller 2
name: Power
device_id: controller_2
lambda: return true;
turn_on_action: []
turn_off_action: []
- platform: template
name: Power Controller 3
name: Power
device_id: controller_3
lambda: return false;
turn_on_action: []
@@ -117,54 +117,26 @@ switch:
turn_on_action: []
turn_off_action: []
# Scenario 5: Buttons with unique names
# Scenario 5: Empty names on different devices (should use device name)
button:
- platform: template
name: "Reset Controller 1"
name: ""
device_id: controller_1
on_press: []
- platform: template
name: "Reset Controller 2"
name: ""
device_id: controller_2
on_press: []
- platform: template
name: "Reset Main"
name: ""
on_press: [] # Main device
# 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
# Scenario 6: Special characters in names
number:
- platform: template
name: "Temperature Setpoint! Controller 1"
name: "Temperature Setpoint!"
device_id: controller_1
min_value: 10.0
max_value: 30.0
@@ -173,7 +145,7 @@ number:
set_action: []
- platform: template
name: "Temperature Setpoint! Controller 2"
name: "Temperature Setpoint!"
device_id: controller_2
min_value: 10.0
max_value: 30.0
+149 -9
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
import asyncio
from aioesphomeapi import EntityState
from aioesphomeapi import EntityState, SwitchInfo, SwitchState
import pytest
from .types import APIClientConnectedFactory, RunCompiledFunction
@@ -84,23 +84,45 @@ async def test_areas_and_devices(
# Subscribe to states to get sensor values
loop = asyncio.get_running_loop()
states: dict[int, EntityState] = {}
states_future: asyncio.Future[bool] = loop.create_future()
states: dict[tuple[int, int], EntityState] = {}
# 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:
states[state.key] = state
# Check if we have all expected sensor states
if len(states) >= 4 and not states_future.done():
states_future.set_result(True)
state_key = (state.device_id, state.key)
states[state_key] = state
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)
# Wait for sensor states
try:
await asyncio.wait_for(states_future, timeout=10.0)
await asyncio.wait_for(initial_states_future, timeout=10.0)
except TimeoutError:
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"
)
@@ -119,3 +141,121 @@ async def test_areas_and_devices(
f"{entity.name} has device_id {entity.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"
File diff suppressed because it is too large Load Diff
+18 -29
View File
@@ -510,13 +510,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,36 +535,24 @@ def test_entity_duplicate_validator_with_devices() -> None:
device1 = ID("device1", 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}
validated1 = validator(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}
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. 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)
# 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
validator(config3)
def test_duplicate_entity_yaml_validation(
@@ -588,10 +576,10 @@ def test_duplicate_entity_with_devices_yaml_validation(
)
assert result is None
# Check for the duplicate entity error message
# Check for the duplicate entity error message with device
captured = capsys.readouterr()
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
)
@@ -616,21 +604,22 @@ def test_entity_duplicate_validator_internal_entities() -> None:
config1 = {CONF_NAME: "Temperature"}
validated1 = validator(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)
config2 = {CONF_NAME: "Temperature", CONF_INTERNAL: True}
validated2 = validator(config2)
assert validated2 == config2
# 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
config3 = {CONF_NAME: "Temperature", CONF_INTERNAL: True}
validated3 = validator(config3)
assert validated3 == config3
# 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
config4 = {CONF_NAME: "Temperature"}