diff --git a/homeassistant/components/esphome/entity.py b/homeassistant/components/esphome/entity.py index a4d24e0e9b4..fcf1260de39 100644 --- a/homeassistant/components/esphome/entity.py +++ b/homeassistant/components/esphome/entity.py @@ -277,6 +277,7 @@ class EsphomeEntity(EsphomeBaseEntity, Generic[_InfoT, _StateT]): self._state_type = state_type self._on_static_info_update(entity_info) + device_name = device_info.name # Determine the device connection based on whether this entity belongs to a sub device if entity_info.device_id: # Entity belongs to a sub device @@ -285,6 +286,10 @@ class EsphomeEntity(EsphomeBaseEntity, Generic[_InfoT, _StateT]): (DOMAIN, f"{device_info.mac_address}_{entity_info.device_id}") } ) + # Use the pre-computed device_id_to_name mapping for O(1) lookup + device_name = entry_data.device_id_to_name.get( + entity_info.device_id, device_info.name + ) else: # Entity belongs to the main device self._attr_device_info = DeviceInfo( @@ -292,7 +297,7 @@ class EsphomeEntity(EsphomeBaseEntity, Generic[_InfoT, _StateT]): ) if entity_info.name: - self.entity_id = f"{domain}.{device_info.name}_{entity_info.object_id}" + self.entity_id = f"{domain}.{device_name}_{entity_info.object_id}" else: # https://github.com/home-assistant/core/issues/132532 # If name is not set, ESPHome will use the sanitized friendly name @@ -300,7 +305,7 @@ class EsphomeEntity(EsphomeBaseEntity, Generic[_InfoT, _StateT]): # as the entity_id before it is sanitized since the sanitizer # is not utf-8 aware. In this case, its always going to be # an empty string so we drop the object_id. - self.entity_id = f"{domain}.{device_info.name}" + self.entity_id = f"{domain}.{device_name}" async def async_added_to_hass(self) -> None: """Register callbacks.""" diff --git a/homeassistant/components/esphome/entry_data.py b/homeassistant/components/esphome/entry_data.py index 1e6375d8caf..bf8c0918652 100644 --- a/homeassistant/components/esphome/entry_data.py +++ b/homeassistant/components/esphome/entry_data.py @@ -160,6 +160,7 @@ class RuntimeEntryData: assist_satellite_set_wake_word_callbacks: list[Callable[[str], None]] = field( default_factory=list ) + device_id_to_name: dict[int, str] = field(default_factory=dict) @property def name(self) -> str: diff --git a/homeassistant/components/esphome/manager.py b/homeassistant/components/esphome/manager.py index 0da13ab973d..6c2da31e48b 100644 --- a/homeassistant/components/esphome/manager.py +++ b/homeassistant/components/esphome/manager.py @@ -527,6 +527,11 @@ class ESPHomeManager: device_info.name, device_mac, ) + # Build device_id_to_name mapping for efficient lookup + entry_data.device_id_to_name = { + sub_device.device_id: sub_device.name or device_info.name + for sub_device in device_info.devices + } self.device_id = _async_setup_device_registry(hass, entry, entry_data) entry_data.async_update_device_state() diff --git a/tests/components/esphome/test_entity.py b/tests/components/esphome/test_entity.py index 15fbea68e76..b43882fdfe8 100644 --- a/tests/components/esphome/test_entity.py +++ b/tests/components/esphome/test_entity.py @@ -779,7 +779,7 @@ async def test_entity_assignment_to_sub_device( ) assert sub_device_1 is not None - motion_sensor = entity_registry.async_get("binary_sensor.test_motion") + motion_sensor = entity_registry.async_get("binary_sensor.motion_sensor_motion") assert motion_sensor is not None assert motion_sensor.device_id == sub_device_1.id @@ -789,14 +789,14 @@ async def test_entity_assignment_to_sub_device( ) assert sub_device_2 is not None - door_sensor = entity_registry.async_get("binary_sensor.test_door") + door_sensor = entity_registry.async_get("binary_sensor.door_sensor_door") assert door_sensor is not None assert door_sensor.device_id == sub_device_2.id # Check states assert hass.states.get("binary_sensor.test_main_sensor").state == STATE_ON - assert hass.states.get("binary_sensor.test_motion").state == STATE_OFF - assert hass.states.get("binary_sensor.test_door").state == STATE_ON + assert hass.states.get("binary_sensor.motion_sensor_motion").state == STATE_OFF + assert hass.states.get("binary_sensor.door_sensor_door").state == STATE_ON # Check entity friendly names # Main device entity should have: "{device_name} {entity_name}" @@ -804,11 +804,11 @@ async def test_entity_assignment_to_sub_device( assert main_sensor_state.attributes[ATTR_FRIENDLY_NAME] == "Test Main Sensor" # Sub device 1 entity should have: "Motion Sensor Motion" - motion_sensor_state = hass.states.get("binary_sensor.test_motion") + motion_sensor_state = hass.states.get("binary_sensor.motion_sensor_motion") assert motion_sensor_state.attributes[ATTR_FRIENDLY_NAME] == "Motion Sensor Motion" # Sub device 2 entity should have: "Door Sensor Door" - door_sensor_state = hass.states.get("binary_sensor.test_door") + door_sensor_state = hass.states.get("binary_sensor.door_sensor_door") assert door_sensor_state.attributes[ATTR_FRIENDLY_NAME] == "Door Sensor Door" @@ -879,6 +879,7 @@ async def test_entity_friendly_names_with_empty_device_names( ) # Check entity friendly name on sub-device with empty name + # Since sub device has empty name, it falls back to main device name "test" state_1 = hass.states.get("binary_sensor.test_motion") assert state_1 is not None # With has_entity_name, friendly name is "{device_name} {entity_name}" @@ -886,13 +887,13 @@ async def test_entity_friendly_names_with_empty_device_names( assert state_1.attributes[ATTR_FRIENDLY_NAME] == "Main Device Motion Detected" # Check entity friendly name on sub-device with valid name - state_2 = hass.states.get("binary_sensor.test_status") + state_2 = hass.states.get("binary_sensor.kitchen_light_status") assert state_2 is not None # Device has name "Kitchen Light", entity has name "Status" assert state_2.attributes[ATTR_FRIENDLY_NAME] == "Kitchen Light Status" # Test entity with empty name on sub-device - state_3 = hass.states.get("binary_sensor.test") + state_3 = hass.states.get("binary_sensor.kitchen_light") assert state_3 is not None # Entity has empty name, so friendly name is just the device name assert state_3.attributes[ATTR_FRIENDLY_NAME] == "Kitchen Light" @@ -1030,3 +1031,134 @@ async def test_entity_switches_between_devices( sensor_entity = entity_registry.async_get("binary_sensor.test_sensor") assert sensor_entity is not None assert sensor_entity.device_id == main_device.id + + +async def test_entity_id_uses_sub_device_name( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, +) -> None: + """Test that entity_id uses sub device name when entity belongs to sub device.""" + # Define sub devices + sub_devices = [ + SubDeviceInfo(device_id=11111111, name="motion_sensor", area_id=0), + SubDeviceInfo(device_id=22222222, name="door_sensor", area_id=0), + ] + + device_info = { + "devices": sub_devices, + "name": "main_device", + } + + # Create entities that belong to different devices + entity_info = [ + # Entity for main device (device_id=0) + BinarySensorInfo( + object_id="main_sensor", + key=1, + name="Main Sensor", + unique_id="main_sensor", + device_id=0, + ), + # Entity for sub device 1 + BinarySensorInfo( + object_id="motion", + key=2, + name="Motion", + unique_id="motion", + device_id=11111111, + ), + # Entity for sub device 2 + BinarySensorInfo( + object_id="door", + key=3, + name="Door", + unique_id="door", + device_id=22222222, + ), + # Entity without name on sub device + BinarySensorInfo( + object_id="sensor_no_name", + key=4, + name="", + unique_id="sensor_no_name", + device_id=11111111, + ), + ] + + states = [ + BinarySensorState(key=1, state=True, missing_state=False), + BinarySensorState(key=2, state=False, missing_state=False), + BinarySensorState(key=3, state=True, missing_state=False), + BinarySensorState(key=4, state=True, missing_state=False), + ] + + await mock_esphome_device( + mock_client=mock_client, + device_info=device_info, + entity_info=entity_info, + states=states, + ) + + # Check entity_id for main device entity + # Should be: binary_sensor.{main_device_name}_{object_id} + assert hass.states.get("binary_sensor.main_device_main_sensor") is not None + + # Check entity_id for sub device 1 entity + # Should be: binary_sensor.{sub_device_name}_{object_id} + assert hass.states.get("binary_sensor.motion_sensor_motion") is not None + + # Check entity_id for sub device 2 entity + # Should be: binary_sensor.{sub_device_name}_{object_id} + assert hass.states.get("binary_sensor.door_sensor_door") is not None + + # Check entity_id for entity without name on sub device + # Should be: binary_sensor.{sub_device_name} + assert hass.states.get("binary_sensor.motion_sensor") is not None + + +async def test_entity_id_with_empty_sub_device_name( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, +) -> None: + """Test entity_id when sub device has empty name (falls back to main device name).""" + # Define sub device with empty name + sub_devices = [ + SubDeviceInfo(device_id=11111111, name="", area_id=0), # Empty name + ] + + device_info = { + "devices": sub_devices, + "name": "main_device", + } + + # Create entity on sub device with empty name + entity_info = [ + BinarySensorInfo( + object_id="sensor", + key=1, + name="Sensor", + unique_id="sensor", + device_id=11111111, + ), + ] + + states = [ + BinarySensorState(key=1, state=True, missing_state=False), + ] + + await mock_esphome_device( + mock_client=mock_client, + device_info=device_info, + entity_info=entity_info, + states=states, + ) + + # When sub device has empty name, entity_id should use main device name + # Should be: binary_sensor.{main_device_name}_{object_id} + assert hass.states.get("binary_sensor.main_device_sensor") is not None