From 2d8957b5ce7c7a68c8fd96a7802a831407a9e5ee Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:07:59 +0200 Subject: [PATCH] Add parent device name part to generated entity IDs (#179996) --- homeassistant/helpers/entity_registry.py | 24 +- .../guntamatic/snapshots/test_sensor.ambr | 488 +++++++++--------- tests/components/guntamatic/test_sensor.py | 2 +- .../kitchen_sink/snapshots/test_sensor.ambr | 8 +- .../kitchen_sink/snapshots/test_switch.ambr | 8 +- tests/components/kitchen_sink/test_sensor.py | 5 +- tests/components/kitchen_sink/test_switch.py | 5 +- tests/helpers/test_entity_platform.py | 4 +- tests/helpers/test_entity_registry.py | 61 +++ 9 files changed, 347 insertions(+), 258 deletions(-) diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index a2a2ce1ca295..111fb3c4a75f 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -150,6 +150,7 @@ class EntityNamePart(StrEnum): DEVICE = "device" ENTITY = "entity" FLOOR = "floor" + PARENT_DEVICE = "parent_device" @dataclass(frozen=True, kw_only=True, slots=True) @@ -521,11 +522,26 @@ def _async_get_full_entity_name( elif not use_legacy_naming or name is None: device_name: str | None = None + parent_device_name: str | None = None if device_id is not None: device_registry = dr.async_get(hass) if (device := device_registry.async_get(device_id)) is not None: device_name = device.name_by_user or device.name + if ( + EntityNamePart.PARENT_DEVICE in parts + and isinstance(device, dr.ChildDeviceEntry) + and ( + parent_device := device_registry.async_get( + device.parent_device_id, include_child_devices=False + ) + ) + is not None + ): + parent_device_name = ( + parent_device.name_by_user or parent_device.name + ) + if area_id is None: area_id = dr.async_get_effective_area_id(hass, device) @@ -569,6 +585,7 @@ def _async_get_full_entity_name( EntityNamePart.DEVICE: device_name, EntityNamePart.ENTITY: entity_name, EntityNamePart.FLOOR: floor_name, + EntityNamePart.PARENT_DEVICE: parent_device_name, } full_name = " ".join( part_name for part in parts if (part_name := part_names[part]) @@ -1370,7 +1387,12 @@ class EntityRegistry(BaseRegistry): """ parts = self.settings.entity_id_parts if parts is None: - parts = (EntityNamePart.AREA, EntityNamePart.DEVICE, EntityNamePart.ENTITY) + parts = ( + EntityNamePart.AREA, + EntityNamePart.PARENT_DEVICE, + EntityNamePart.DEVICE, + EntityNamePart.ENTITY, + ) object_id = _async_get_full_entity_name( self.hass, area_id=area_id, diff --git a/tests/components/guntamatic/snapshots/test_sensor.ambr b/tests/components/guntamatic/snapshots/test_sensor.ambr index da894fa4dd7d..fe8a852bf5a3 100644 --- a/tests/components/guntamatic/snapshots/test_sensor.ambr +++ b/tests/components/guntamatic/snapshots/test_sensor.ambr @@ -1,248 +1,4 @@ # serializer version: 1 -# name: test_all_entities[sensor.heating_circuit_1_flow_temperature-entry] - EntityRegistryEntrySnapshot({ - 'aliases': list([ - None, - ]), - 'area_id': None, - 'capabilities': dict({ - : , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.heating_circuit_1_flow_temperature', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'object_id_base': 'Flow temperature', - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 1, - }), - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Flow temperature', - 'platform': 'guntamatic', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'circuit_temperature', - 'unique_id': '959103_circuit_1_temp', - 'unit_of_measurement': , - }) -# --- -# name: test_all_entities[sensor.heating_circuit_1_flow_temperature-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - : 'temperature', - : 'Heating circuit 1 Flow temperature', - : , - : , - }), - 'context': , - 'entity_id': 'sensor.heating_circuit_1_flow_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '55.00', - }) -# --- -# name: test_all_entities[sensor.heating_circuit_1_program-entry] - EntityRegistryEntrySnapshot({ - 'aliases': list([ - None, - ]), - 'area_id': None, - 'capabilities': dict({ - : list([ - 'off', - 'timer', - 'heat', - 'hibernate', - 'hibernate_to', - ]), - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.heating_circuit_1_program', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'object_id_base': 'Program', - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Program', - 'platform': 'guntamatic', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'heating_circulation_program', - 'unique_id': '959103_heating_circulation_program_1', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[sensor.heating_circuit_1_program-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - : 'enum', - : 'Heating circuit 1 Program', - : list([ - 'off', - 'timer', - 'heat', - 'hibernate', - 'hibernate_to', - ]), - }), - 'context': , - 'entity_id': 'sensor.heating_circuit_1_program', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'heat', - }) -# --- -# name: test_all_entities[sensor.heating_circuit_1_pump-entry] - EntityRegistryEntrySnapshot({ - 'aliases': list([ - None, - ]), - 'area_id': None, - 'capabilities': dict({ - : list([ - 'auto', - 'off', - 'nonstop', - ]), - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.heating_circuit_1_pump', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'object_id_base': 'Pump', - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Pump', - 'platform': 'guntamatic', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'heating_circulation_pump', - 'unique_id': '959103_heating_circulation_pump_1', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[sensor.heating_circuit_1_pump-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - : 'enum', - : 'Heating circuit 1 Pump', - : list([ - 'auto', - 'off', - 'nonstop', - ]), - }), - 'context': , - 'entity_id': 'sensor.heating_circuit_1_pump', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'auto', - }) -# --- -# name: test_all_entities[sensor.heating_circuit_1_room_temperature-entry] - EntityRegistryEntrySnapshot({ - 'aliases': list([ - None, - ]), - 'area_id': None, - 'capabilities': dict({ - : , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.heating_circuit_1_room_temperature', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'object_id_base': 'Room temperature', - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 1, - }), - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Room temperature', - 'platform': 'guntamatic', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'room_temperature', - 'unique_id': '959103_room_1_temperature', - 'unit_of_measurement': , - }) -# --- -# name: test_all_entities[sensor.heating_circuit_1_room_temperature-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - : 'temperature', - : 'Heating circuit 1 Room temperature', - : , - : , - }), - 'context': , - 'entity_id': 'sensor.heating_circuit_1_room_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '21.50', - }) -# --- # name: test_all_entities[sensor.mock_title_auxiliary_pump_1-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -1283,6 +1039,250 @@ 'state': '12', }) # --- +# name: test_all_entities[sensor.mock_title_heating_circuit_1_flow_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.mock_title_heating_circuit_1_flow_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Flow temperature', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Flow temperature', + 'platform': 'guntamatic', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'circuit_temperature', + 'unique_id': '959103_circuit_1_temp', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.mock_title_heating_circuit_1_flow_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'temperature', + : 'Heating circuit 1 Flow temperature', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.mock_title_heating_circuit_1_flow_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '55.00', + }) +# --- +# name: test_all_entities[sensor.mock_title_heating_circuit_1_program-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'off', + 'timer', + 'heat', + 'hibernate', + 'hibernate_to', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_title_heating_circuit_1_program', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Program', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Program', + 'platform': 'guntamatic', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'heating_circulation_program', + 'unique_id': '959103_heating_circulation_program_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.mock_title_heating_circuit_1_program-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'enum', + : 'Heating circuit 1 Program', + : list([ + 'off', + 'timer', + 'heat', + 'hibernate', + 'hibernate_to', + ]), + }), + 'context': , + 'entity_id': 'sensor.mock_title_heating_circuit_1_program', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- +# name: test_all_entities[sensor.mock_title_heating_circuit_1_pump-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'auto', + 'off', + 'nonstop', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.mock_title_heating_circuit_1_pump', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Pump', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Pump', + 'platform': 'guntamatic', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'heating_circulation_pump', + 'unique_id': '959103_heating_circulation_pump_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.mock_title_heating_circuit_1_pump-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'enum', + : 'Heating circuit 1 Pump', + : list([ + 'auto', + 'off', + 'nonstop', + ]), + }), + 'context': , + 'entity_id': 'sensor.mock_title_heating_circuit_1_pump', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'auto', + }) +# --- +# name: test_all_entities[sensor.mock_title_heating_circuit_1_room_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_title_heating_circuit_1_room_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Room temperature', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Room temperature', + 'platform': 'guntamatic', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'room_temperature', + 'unique_id': '959103_room_1_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.mock_title_heating_circuit_1_room_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'temperature', + : 'Heating circuit 1 Room temperature', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.mock_title_heating_circuit_1_room_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '21.50', + }) +# --- # name: test_all_entities[sensor.mock_title_interruption_1-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/guntamatic/test_sensor.py b/tests/components/guntamatic/test_sensor.py index 0fe7458a60e1..e144b12af07b 100644 --- a/tests/components/guntamatic/test_sensor.py +++ b/tests/components/guntamatic/test_sensor.py @@ -171,7 +171,7 @@ async def test_enum_sensor_unmapped_value( await setup_integration(hass, mock_config_entry) await hass.async_block_till_done() - state = hass.states.get("sensor.heating_circuit_1_pump") + state = hass.states.get("sensor.mock_title_heating_circuit_1_pump") assert state is not None assert state.state == STATE_UNKNOWN diff --git a/tests/components/kitchen_sink/snapshots/test_sensor.ambr b/tests/components/kitchen_sink/snapshots/test_sensor.ambr index 47be1098dfa6..79a793ae5df1 100644 --- a/tests/components/kitchen_sink/snapshots/test_sensor.ambr +++ b/tests/components/kitchen_sink/snapshots/test_sensor.ambr @@ -9,7 +9,7 @@ : , }), 'context': , - 'entity_id': 'sensor.outlet_1_power', + 'entity_id': 'sensor.power_strip_with_2_sockets_outlet_1_power', 'last_changed': , 'last_reported': , 'last_updated': , @@ -23,7 +23,7 @@ : , }), 'context': , - 'entity_id': 'sensor.outlet_2_power', + 'entity_id': 'sensor.power_strip_with_2_sockets_outlet_2_power', 'last_changed': , 'last_reported': , 'last_updated': , @@ -93,7 +93,7 @@ : , }), 'context': , - 'entity_id': 'sensor.outlet_1_power', + 'entity_id': 'sensor.power_strip_with_2_sockets_outlet_1_power', 'last_changed': , 'last_reported': , 'last_updated': , @@ -107,7 +107,7 @@ : , }), 'context': , - 'entity_id': 'sensor.outlet_2_power', + 'entity_id': 'sensor.power_strip_with_2_sockets_outlet_2_power', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/kitchen_sink/snapshots/test_switch.ambr b/tests/components/kitchen_sink/snapshots/test_switch.ambr index beeb487a295c..7093c991ba8a 100644 --- a/tests/components/kitchen_sink/snapshots/test_switch.ambr +++ b/tests/components/kitchen_sink/snapshots/test_switch.ambr @@ -5,7 +5,7 @@ : 'Outlet 1', }), 'context': , - 'entity_id': 'switch.outlet_1', + 'entity_id': 'switch.power_strip_with_2_sockets_outlet_1', 'last_changed': , 'last_reported': , 'last_updated': , @@ -26,7 +26,7 @@ 'disabled_by': None, 'domain': 'switch', 'entity_category': None, - 'entity_id': 'switch.outlet_1', + 'entity_id': 'switch.power_strip_with_2_sockets_outlet_1', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -105,7 +105,7 @@ : 'Outlet 2', }), 'context': , - 'entity_id': 'switch.outlet_2', + 'entity_id': 'switch.power_strip_with_2_sockets_outlet_2', 'last_changed': , 'last_reported': , 'last_updated': , @@ -126,7 +126,7 @@ 'disabled_by': None, 'domain': 'switch', 'entity_category': None, - 'entity_id': 'switch.outlet_2', + 'entity_id': 'switch.power_strip_with_2_sockets_outlet_2', 'has_entity_name': True, 'hidden_by': None, 'icon': None, diff --git a/tests/components/kitchen_sink/test_sensor.py b/tests/components/kitchen_sink/test_sensor.py index f1c11f6a1648..2b691a5ac0cd 100644 --- a/tests/components/kitchen_sink/test_sensor.py +++ b/tests/components/kitchen_sink/test_sensor.py @@ -46,7 +46,10 @@ async def test_outlet_power_sensors_on_child_devices( entity_registry: er.EntityRegistry, ) -> None: """Test the outlet power sensors are placed on child devices of the power strip.""" - for entity_id in ("sensor.outlet_1_power", "sensor.outlet_2_power"): + for entity_id in ( + "sensor.power_strip_with_2_sockets_outlet_1_power", + "sensor.power_strip_with_2_sockets_outlet_2_power", + ): entity_entry = entity_registry.async_get(entity_id) assert entity_entry is not None child_device = device_registry.async_get(entity_entry.device_id) diff --git a/tests/components/kitchen_sink/test_switch.py b/tests/components/kitchen_sink/test_switch.py index a00247ca169c..cab56e398eb6 100644 --- a/tests/components/kitchen_sink/test_switch.py +++ b/tests/components/kitchen_sink/test_switch.py @@ -17,7 +17,10 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component -SWITCH_ENTITY_IDS = ["switch.outlet_1", "switch.outlet_2"] +SWITCH_ENTITY_IDS = [ + "switch.power_strip_with_2_sockets_outlet_1", + "switch.power_strip_with_2_sockets_outlet_2", +] @pytest.fixture diff --git a/tests/helpers/test_entity_platform.py b/tests/helpers/test_entity_platform.py index d6f8326de14a..cb2a02111efa 100644 --- a/tests/helpers/test_entity_platform.py +++ b/tests/helpers/test_entity_platform.py @@ -3077,8 +3077,8 @@ async def test_device_info_child_device( entity_id = entity_registry.async_get_entity_id( "test_domain", config_entry.domain, "power" ) - # The child device's name is the device part of the generated entity id - assert entity_id == "test_domain.outlet_1_power" + # The generated entity id includes the parent and child device names + assert entity_id == "test_domain.power_strip_outlet_1_power" entry = entity_registry.async_get(entity_id) assert entry is not None assert entry.device_id == child_device.id diff --git a/tests/helpers/test_entity_registry.py b/tests/helpers/test_entity_registry.py index 39e13a77340f..0323d35c755e 100644 --- a/tests/helpers/test_entity_registry.py +++ b/tests/helpers/test_entity_registry.py @@ -1176,6 +1176,67 @@ def test_generate_entity_id_parts_entity_area( assert new_entity_id == "sensor.second_floor_garage_lamp_temperature" +def test_generate_entity_id_parent_device_part( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test the parent device part for entities on child devices.""" + config_entry = MockConfigEntry(domain="sensor") + config_entry.add_to_hass(hass) + + parent_device = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={("test", "strip")}, + name="Power strip", + ) + child_device = device_registry.async_get_or_create_child( + config_entry_id=config_entry.entry_id, + identifiers={("test", "strip_outlet_1")}, + parent_device_id=parent_device.id, + name="Outlet 1", + ) + + # The default parts include the parent device name + entry = entity_registry.async_get_or_create( + "sensor", + "test", + "1234", + config_entry=config_entry, + device_id=child_device.id, + has_entity_name=True, + object_id_base="Power", + original_name="Power", + ) + assert entry.entity_id == "sensor.power_strip_outlet_1_power" + + # A parent device name set by the user is used + device_registry.async_update_device(parent_device.id, name_by_user="Kitchen strip") + assert ( + entity_registry.async_regenerate_entity_id(entry) + == "sensor.kitchen_strip_outlet_1_power" + ) + + # An entity on a main device is not affected by the parent device part + main_entry = entity_registry.async_get_or_create( + "sensor", + "test", + "5678", + config_entry=config_entry, + device_id=parent_device.id, + has_entity_name=True, + object_id_base="Power", + original_name="Power", + ) + assert main_entry.entity_id == "sensor.kitchen_strip_power" + + # Parts without the parent device part exclude the parent device name + entity_registry.async_update_settings( + entity_id_parts=[er.EntityNamePart.DEVICE, er.EntityNamePart.ENTITY] + ) + assert entity_registry.async_regenerate_entity_id(entry) == "sensor.outlet_1_power" + + def test_regenerate_entity_id_after_settings_change( hass: HomeAssistant, area_registry: ar.AreaRegistry,