From e40b45cab1c7d22f3e70bddc0164cd26487ebecb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 15 Jul 2025 23:34:51 -1000 Subject: [PATCH] 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> --- esphome/components/api/api_connection.cpp | 23 ++- esphome/core/application.h | 20 ++- esphome/core/entity_helpers.py | 10 +- .../fixtures/areas_and_devices.yaml | 42 +++++ ...licate_entities_on_different_devices.yaml} | 80 +++------ tests/integration/test_areas_and_devices.py | 158 +++++++++++++++++- tests/integration/test_duplicate_entities.py | 135 ++++++--------- tests/unit_tests/core/test_entity_helpers.py | 47 ++---- 8 files changed, 330 insertions(+), 185 deletions(-) rename tests/integration/fixtures/{duplicate_entities_not_allowed_on_different_devices.yaml => duplicate_entities_on_different_devices.yaml} (61%) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index b9b85d853d..c89a20d9eb 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -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 sock, APIServer *parent) : parent_(parent), initial_state_iterator_(this), list_entities_iterator_(this) { diff --git a/esphome/core/application.h b/esphome/core/application.h index f2b5cb5c89..75b9769ca3 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -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 &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 &get_devices() { return this->devices_; } -#endif +#endif // USE_DEVICES #ifdef USE_AREAS const std::vector &get_areas() { return this->areas_; } #endif diff --git a/esphome/core/entity_helpers.py b/esphome/core/entity_helpers.py index 5ad16ac76c..cc388ffb4c 100644 --- a/esphome/core/entity_helpers.py +++ b/esphome/core/entity_helpers.py @@ -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 diff --git a/tests/integration/fixtures/areas_and_devices.yaml b/tests/integration/fixtures/areas_and_devices.yaml index 6bf1519c79..12ab070e55 100644 --- a/tests/integration/fixtures/areas_and_devices.yaml +++ b/tests/integration/fixtures/areas_and_devices.yaml @@ -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" diff --git a/tests/integration/fixtures/duplicate_entities_not_allowed_on_different_devices.yaml b/tests/integration/fixtures/duplicate_entities_on_different_devices.yaml similarity index 61% rename from tests/integration/fixtures/duplicate_entities_not_allowed_on_different_devices.yaml rename to tests/integration/fixtures/duplicate_entities_on_different_devices.yaml index f7d017a0ae..ecc502ad28 100644 --- a/tests/integration/fixtures/duplicate_entities_not_allowed_on_different_devices.yaml +++ b/tests/integration/fixtures/duplicate_entities_on_different_devices.yaml @@ -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 diff --git a/tests/integration/test_areas_and_devices.py b/tests/integration/test_areas_and_devices.py index 55c96d896d..1af16c87e8 100644 --- a/tests/integration/test_areas_and_devices.py +++ b/tests/integration/test_areas_and_devices.py @@ -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" diff --git a/tests/integration/test_duplicate_entities.py b/tests/integration/test_duplicate_entities.py index 2c1fcba0eb..c738bb3fe0 100644 --- a/tests/integration/test_duplicate_entities.py +++ b/tests/integration/test_duplicate_entities.py @@ -11,12 +11,12 @@ from .types import APIClientConnectedFactory, RunCompiledFunction @pytest.mark.asyncio -async def test_duplicate_entities_not_allowed_on_different_devices( +async def test_duplicate_entities_on_different_devices( yaml_config: str, run_compiled: RunCompiledFunction, api_client_connected: APIClientConnectedFactory, ) -> 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: # Get 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"] 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 that temperature sensors have unique names per device - temp_sensors = [s for s in sensors if "Temperature" in s.name] + # Scenario 1: Check sensors with same "Temperature" name on different devices + temp_sensors = [s for s in sensors if s.name == "Temperature"] assert len(temp_sensors) == 4, ( f"Expected exactly 4 temperature sensors, got {len(temp_sensors)}" ) - # Verify each sensor has a unique name - temp_names = set() + # Verify each sensor is on a different device + temp_device_ids = set() temp_object_ids = set() for sensor in temp_sensors: - temp_names.add(sensor.name) + temp_device_ids.add(sensor.device_id) temp_object_ids.add(sensor.object_id) - # Should have 4 unique names - assert len(temp_names) == 4, ( - f"Temperature sensors should have unique names, got {temp_names}" + # 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}" ) - # 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] + # Scenario 2: Check binary sensors "Status" on different devices + status_binary = [b for b in binary_sensors if b.name == "Status"] assert len(status_binary) == 3, ( f"Expected exactly 3 status binary sensors, got {len(status_binary)}" ) - # All should have unique object_ids - status_names = set() + # All should have object_id "status" for binary in status_binary: - status_names.add(binary.name) - - assert len(status_names) == 3, ( - f"Status binary sensors should have unique names, got {status_names}" - ) + assert binary.object_id == "status", ( + f"Expected object_id 'status', got '{binary.object_id}'" + ) # Scenario 3: Check that sensor and binary_sensor can have same name 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" - # Scenario 4: Check text sensors have unique names - info_text = [t for t in text_sensors if "Device Info" in t.name] + # Scenario 4: Check text sensors "Device Info" on different devices + info_text = [t for t in text_sensors if t.name == "Device Info"] assert len(info_text) == 3, ( f"Expected exactly 3 device info text sensors, got {len(info_text)}" ) - # All should have unique names and object_ids - info_names = set() + # All should have object_id "device_info" 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, ( - f"Device info text sensors should have unique names, got {info_names}" + # 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)}" ) - # 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)}" - ) - - # All should have unique names - power_names = set() + # All should have object_id "power" 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, ( - 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)}" + # 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)}" ) # Group by device - 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] + 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] # 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 - 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" + # 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" assert ( - len(main_selects) == 1 - and main_selects[0].object_id == "duplicate-entities-test" + len(main_buttons) == 1 + and main_buttons[0].object_id == "duplicate-entities-test" ) - # Scenario 8: Check special characters in number names - now unique - temp_numbers = [n for n in numbers if "Temperature Setpoint!" in n.name] + # Scenario 7: Check special characters in number names + temp_numbers = [n for n in numbers if n.name == "Temperature Setpoint!"] assert len(temp_numbers) == 2, ( f"Expected exactly 2 temperature setpoint numbers, got {len(temp_numbers)}" ) - # Should have unique names - setpoint_names = set() + # Special characters should be sanitized to _ in object_id for number in temp_numbers: - setpoint_names.add(number.name) - - assert len(setpoint_names) == 2, ( - f"Temperature setpoint numbers should have unique names, got {setpoint_names}" - ) + assert number.object_id == "temperature_setpoint_", ( + f"Expected object_id 'temperature_setpoint_', got '{number.object_id}'" + ) # Verify we can get states for all entities (ensures they're functional) loop = asyncio.get_running_loop() @@ -192,7 +164,6 @@ async def test_duplicate_entities_not_allowed_on_different_devices( + len(switches) + len(buttons) + len(numbers) - + len(selects) ) def on_state(state) -> None: diff --git a/tests/unit_tests/core/test_entity_helpers.py b/tests/unit_tests/core/test_entity_helpers.py index 4f256ffb33..c639ad94b2 100644 --- a/tests/unit_tests/core/test_entity_helpers.py +++ b/tests/unit_tests/core/test_entity_helpers.py @@ -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"}