From 26a85c6644991f626ccce62c05665095c2577234 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 28 Jun 2022 18:38:05 +0200 Subject: [PATCH] Add Entity.has_entity_name attribute (#73217) --- .../components/config/entity_registry.py | 1 + homeassistant/helpers/entity.py | 34 ++++++++++- homeassistant/helpers/entity_platform.py | 25 +++++--- homeassistant/helpers/entity_registry.py | 19 +++++- tests/common.py | 5 ++ .../components/config/test_entity_registry.py | 8 +++ tests/helpers/test_entity.py | 60 +++++++++++++++++-- tests/helpers/test_entity_platform.py | 46 ++++++++++++++ tests/helpers/test_entity_registry.py | 6 ++ 9 files changed, 186 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/config/entity_registry.py b/homeassistant/components/config/entity_registry.py index 2bb585e12c6..e6b91ee5a50 100644 --- a/homeassistant/components/config/entity_registry.py +++ b/homeassistant/components/config/entity_registry.py @@ -218,6 +218,7 @@ def _entry_ext_dict(entry): data = _entry_dict(entry) data["capabilities"] = entry.capabilities data["device_class"] = entry.device_class + data["has_entity_name"] = entry.has_entity_name data["options"] = entry.options data["original_device_class"] = entry.original_device_class data["original_icon"] = entry.original_icon diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 39af16892f5..f00f7d85e76 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -38,7 +38,7 @@ from homeassistant.exceptions import HomeAssistantError, NoEntitySpecifiedError from homeassistant.loader import bind_hass from homeassistant.util import dt as dt_util, ensure_unique_string, slugify -from . import entity_registry as er +from . import device_registry as dr, entity_registry as er from .device_registry import DeviceEntryType from .entity_platform import EntityPlatform from .event import async_track_entity_registry_updated_event @@ -221,6 +221,7 @@ class EntityDescription: entity_registry_visible_default: bool = True force_update: bool = False icon: str | None = None + has_entity_name: bool = False name: str | None = None unit_of_measurement: str | None = None @@ -277,6 +278,7 @@ class Entity(ABC): _attr_device_class: str | None _attr_device_info: DeviceInfo | None = None _attr_entity_category: EntityCategory | None + _attr_has_entity_name: bool _attr_entity_picture: str | None = None _attr_entity_registry_enabled_default: bool _attr_entity_registry_visible_default: bool @@ -303,6 +305,15 @@ class Entity(ABC): """Return a unique ID.""" return self._attr_unique_id + @property + def has_entity_name(self) -> bool: + """Return if the name of the entity is describing only the entity itself.""" + if hasattr(self, "_attr_has_entity_name"): + return self._attr_has_entity_name + if hasattr(self, "entity_description"): + return self.entity_description.has_entity_name + return False + @property def name(self) -> str | None: """Return the name of the entity.""" @@ -583,7 +594,26 @@ class Entity(ABC): if (icon := (entry and entry.icon) or self.icon) is not None: attr[ATTR_ICON] = icon - if (name := (entry and entry.name) or self.name) is not None: + def friendly_name() -> str | None: + """Return the friendly name. + + If has_entity_name is False, this returns self.name + If has_entity_name is True, this returns device.name + self.name + """ + if not self.has_entity_name or not self.registry_entry: + return self.name + + device_registry = dr.async_get(self.hass) + if not (device_id := self.registry_entry.device_id) or not ( + device_entry := device_registry.async_get(device_id) + ): + return self.name + + if not self.name: + return device_entry.name_by_user or device_entry.name + return f"{device_entry.name_by_user or device_entry.name} {self.name}" + + if (name := (entry and entry.name) or friendly_name()) is not None: attr[ATTR_FRIENDLY_NAME] = name if (supported_features := self.supported_features) is not None: diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index ecf2125962a..ec71778af12 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -440,15 +440,6 @@ class EntityPlatform: # Get entity_id from unique ID registration if entity.unique_id is not None: - if entity.entity_id is not None: - requested_entity_id = entity.entity_id - suggested_object_id = split_entity_id(entity.entity_id)[1] - else: - suggested_object_id = entity.name # type: ignore[unreachable] - - if self.entity_namespace is not None: - suggested_object_id = f"{self.entity_namespace} {suggested_object_id}" - if self.config_entry is not None: config_entry_id: str | None = self.config_entry.entry_id else: @@ -503,6 +494,22 @@ class EntityPlatform: except RequiredParameterMissing: pass + if entity.entity_id is not None: + requested_entity_id = entity.entity_id + suggested_object_id = split_entity_id(entity.entity_id)[1] + else: + if device and entity.has_entity_name: # type: ignore[unreachable] + device_name = device.name_by_user or device.name + if not entity.name: + suggested_object_id = device_name + else: + suggested_object_id = f"{device_name} {entity.name}" + if not suggested_object_id: + suggested_object_id = entity.name + + if self.entity_namespace is not None: + suggested_object_id = f"{self.entity_namespace} {suggested_object_id}" + disabled_by: RegistryEntryDisabler | None = None if not entity.entity_registry_enabled_default: disabled_by = RegistryEntryDisabler.INTEGRATION diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index eb5590b7fdf..ff38a48da75 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -60,7 +60,7 @@ SAVE_DELAY = 10 _LOGGER = logging.getLogger(__name__) STORAGE_VERSION_MAJOR = 1 -STORAGE_VERSION_MINOR = 6 +STORAGE_VERSION_MINOR = 7 STORAGE_KEY = "core.entity_registry" # Attributes relevant to describing entity @@ -111,6 +111,7 @@ class RegistryEntry: hidden_by: RegistryEntryHider | None = attr.ib(default=None) icon: str | None = attr.ib(default=None) id: str = attr.ib(factory=uuid_util.random_uuid_hex) + has_entity_name: bool = attr.ib(default=False) name: str | None = attr.ib(default=None) options: Mapping[str, Mapping[str, Any]] = attr.ib( default=None, converter=attr.converters.default_if_none(factory=dict) # type: ignore[misc] @@ -328,6 +329,7 @@ class EntityRegistry: config_entry: ConfigEntry | None = None, device_id: str | None = None, entity_category: EntityCategory | None = None, + has_entity_name: bool | None = None, original_device_class: str | None = None, original_icon: str | None = None, original_name: str | None = None, @@ -349,6 +351,9 @@ class EntityRegistry: config_entry_id=config_entry_id or UNDEFINED, device_id=device_id or UNDEFINED, entity_category=entity_category or UNDEFINED, + has_entity_name=has_entity_name + if has_entity_name is not None + else UNDEFINED, original_device_class=original_device_class or UNDEFINED, original_icon=original_icon or UNDEFINED, original_name=original_name or UNDEFINED, @@ -393,6 +398,7 @@ class EntityRegistry: entity_category=entity_category, entity_id=entity_id, hidden_by=hidden_by, + has_entity_name=has_entity_name or False, original_device_class=original_device_class, original_icon=original_icon, original_name=original_name, @@ -499,6 +505,7 @@ class EntityRegistry: entity_category: EntityCategory | None | UndefinedType = UNDEFINED, hidden_by: RegistryEntryHider | None | UndefinedType = UNDEFINED, icon: str | None | UndefinedType = UNDEFINED, + has_entity_name: bool | UndefinedType = UNDEFINED, name: str | None | UndefinedType = UNDEFINED, new_entity_id: str | UndefinedType = UNDEFINED, new_unique_id: str | UndefinedType = UNDEFINED, @@ -548,6 +555,7 @@ class EntityRegistry: ("entity_category", entity_category), ("hidden_by", hidden_by), ("icon", icon), + ("has_entity_name", has_entity_name), ("name", name), ("original_device_class", original_device_class), ("original_icon", original_icon), @@ -621,6 +629,7 @@ class EntityRegistry: entity_category: EntityCategory | None | UndefinedType = UNDEFINED, hidden_by: RegistryEntryHider | None | UndefinedType = UNDEFINED, icon: str | None | UndefinedType = UNDEFINED, + has_entity_name: bool | UndefinedType = UNDEFINED, name: str | None | UndefinedType = UNDEFINED, new_entity_id: str | UndefinedType = UNDEFINED, new_unique_id: str | UndefinedType = UNDEFINED, @@ -642,6 +651,7 @@ class EntityRegistry: entity_category=entity_category, hidden_by=hidden_by, icon=icon, + has_entity_name=has_entity_name, name=name, new_entity_id=new_entity_id, new_unique_id=new_unique_id, @@ -742,6 +752,7 @@ class EntityRegistry: else None, icon=entity["icon"], id=entity["id"], + has_entity_name=entity["has_entity_name"], name=entity["name"], options=entity["options"], original_device_class=entity["original_device_class"], @@ -778,6 +789,7 @@ class EntityRegistry: "hidden_by": entry.hidden_by, "icon": entry.icon, "id": entry.id, + "has_entity_name": entry.has_entity_name, "name": entry.name, "options": entry.options, "original_device_class": entry.original_device_class, @@ -944,6 +956,11 @@ async def _async_migrate( for entity in data["entities"]: entity["hidden_by"] = None + if old_major_version == 1 and old_minor_version < 7: + # Version 1.6 adds has_entity_name + for entity in data["entities"]: + entity["has_entity_name"] = False + if old_major_version > 1: raise NotImplementedError return data diff --git a/tests/common.py b/tests/common.py index 1a29d0d6dc4..80f0913cace 100644 --- a/tests/common.py +++ b/tests/common.py @@ -1007,6 +1007,11 @@ class MockEntity(entity.Entity): """Return the entity category.""" return self._handle("entity_category") + @property + def has_entity_name(self): + """Return the has_entity_name name flag.""" + return self._handle("has_entity_name") + @property def entity_registry_enabled_default(self): """Return if the entity should be enabled when first added to the entity registry.""" diff --git a/tests/components/config/test_entity_registry.py b/tests/components/config/test_entity_registry.py index e74e43de701..69744817a27 100644 --- a/tests/components/config/test_entity_registry.py +++ b/tests/components/config/test_entity_registry.py @@ -117,6 +117,7 @@ async def test_get_entity(hass, client): "entity_id": "test_domain.name", "hidden_by": None, "icon": None, + "has_entity_name": False, "name": "Hello World", "options": {}, "original_device_class": None, @@ -146,6 +147,7 @@ async def test_get_entity(hass, client): "entity_id": "test_domain.no_name", "hidden_by": None, "icon": None, + "has_entity_name": False, "name": None, "options": {}, "original_device_class": None, @@ -208,6 +210,7 @@ async def test_update_entity(hass, client): "entity_id": "test_domain.world", "hidden_by": "user", # We exchange strings over the WS API, not enums "icon": "icon:after update", + "has_entity_name": False, "name": "after update", "options": {}, "original_device_class": None, @@ -279,6 +282,7 @@ async def test_update_entity(hass, client): "entity_id": "test_domain.world", "hidden_by": "user", # We exchange strings over the WS API, not enums "icon": "icon:after update", + "has_entity_name": False, "name": "after update", "options": {}, "original_device_class": None, @@ -315,6 +319,7 @@ async def test_update_entity(hass, client): "entity_id": "test_domain.world", "hidden_by": "user", # We exchange strings over the WS API, not enums "icon": "icon:after update", + "has_entity_name": False, "name": "after update", "options": {"sensor": {"unit_of_measurement": "beard_second"}}, "original_device_class": None, @@ -373,6 +378,7 @@ async def test_update_entity_require_restart(hass, client): "entity_id": "test_domain.world", "icon": None, "hidden_by": None, + "has_entity_name": False, "name": None, "options": {}, "original_device_class": None, @@ -479,6 +485,7 @@ async def test_update_entity_no_changes(hass, client): "entity_id": "test_domain.world", "hidden_by": None, "icon": None, + "has_entity_name": False, "name": "name of entity", "options": {}, "original_device_class": None, @@ -564,6 +571,7 @@ async def test_update_entity_id(hass, client): "entity_id": "test_domain.planet", "hidden_by": None, "icon": None, + "has_entity_name": False, "name": None, "options": {}, "original_device_class": None, diff --git a/tests/helpers/test_entity.py b/tests/helpers/test_entity.py index 7141c5f0903..b9067a3db1c 100644 --- a/tests/helpers/test_entity.py +++ b/tests/helpers/test_entity.py @@ -12,16 +12,18 @@ import voluptuous as vol from homeassistant.const import ( ATTR_ATTRIBUTION, ATTR_DEVICE_CLASS, + ATTR_FRIENDLY_NAME, STATE_UNAVAILABLE, STATE_UNKNOWN, ) from homeassistant.core import Context, HomeAssistantError -from homeassistant.helpers import entity, entity_registry +from homeassistant.helpers import device_registry as dr, entity, entity_registry as er from tests.common import ( MockConfigEntry, MockEntity, MockEntityPlatform, + MockPlatform, get_test_home_assistant, mock_registry, ) @@ -594,11 +596,11 @@ async def test_set_context_expired(hass): async def test_warn_disabled(hass, caplog): """Test we warn once if we write to a disabled entity.""" - entry = entity_registry.RegistryEntry( + entry = er.RegistryEntry( entity_id="hello.world", unique_id="test-unique-id", platform="test-platform", - disabled_by=entity_registry.RegistryEntryDisabler.USER, + disabled_by=er.RegistryEntryDisabler.USER, ) mock_registry(hass, {"hello.world": entry}) @@ -621,7 +623,7 @@ async def test_warn_disabled(hass, caplog): async def test_disabled_in_entity_registry(hass): """Test entity is removed if we disable entity registry entry.""" - entry = entity_registry.RegistryEntry( + entry = er.RegistryEntry( entity_id="hello.world", unique_id="test-unique-id", platform="test-platform", @@ -640,7 +642,7 @@ async def test_disabled_in_entity_registry(hass): assert hass.states.get("hello.world") is not None entry2 = registry.async_update_entity( - "hello.world", disabled_by=entity_registry.RegistryEntryDisabler.USER + "hello.world", disabled_by=er.RegistryEntryDisabler.USER ) await hass.async_block_till_done() assert entry2 != entry @@ -749,7 +751,7 @@ async def test_setup_source(hass): async def test_removing_entity_unavailable(hass): """Test removing an entity that is still registered creates an unavailable state.""" - entry = entity_registry.RegistryEntry( + entry = er.RegistryEntry( entity_id="hello.world", unique_id="test-unique-id", platform="test-platform", @@ -886,3 +888,49 @@ async def test_entity_description_fallback(): continue assert getattr(ent, field.name) == getattr(ent_with_description, field.name) + + +@pytest.mark.parametrize( + "has_entity_name, entity_name, expected_friendly_name", + ( + (False, "Entity Blu", "Entity Blu"), + (False, None, None), + (True, "Entity Blu", "Device Bla Entity Blu"), + (True, None, "Device Bla"), + ), +) +async def test_friendly_name( + hass, has_entity_name, entity_name, expected_friendly_name +): + """Test entity_id is influenced by entity name.""" + + async def async_setup_entry(hass, config_entry, async_add_entities): + """Mock setup entry method.""" + async_add_entities( + [ + MockEntity( + unique_id="qwer", + device_info={ + "identifiers": {("hue", "1234")}, + "connections": {(dr.CONNECTION_NETWORK_MAC, "abcd")}, + "name": "Device Bla", + }, + has_entity_name=has_entity_name, + name=entity_name, + ), + ] + ) + return True + + platform = MockPlatform(async_setup_entry=async_setup_entry) + config_entry = MockConfigEntry(entry_id="super-mock-id") + entity_platform = MockEntityPlatform( + hass, platform_name=config_entry.domain, platform=platform + ) + + assert await entity_platform.async_setup_entry(config_entry) + await hass.async_block_till_done() + + assert len(hass.states.async_entity_ids()) == 1 + state = hass.states.async_all()[0] + assert state.attributes.get(ATTR_FRIENDLY_NAME) == expected_friendly_name diff --git a/tests/helpers/test_entity_platform.py b/tests/helpers/test_entity_platform.py index 933669ebc53..80a37f9f2fd 100644 --- a/tests/helpers/test_entity_platform.py +++ b/tests/helpers/test_entity_platform.py @@ -1392,3 +1392,49 @@ class SlowEntity(MockEntity): """Make sure control is returned to the event loop on add.""" await asyncio.sleep(0.1) await super().async_added_to_hass() + + +@pytest.mark.parametrize( + "has_entity_name, entity_name, expected_entity_id", + ( + (False, "Entity Blu", "test_domain.entity_blu"), + (False, None, "test_domain.test_qwer"), # Set to _ + (True, "Entity Blu", "test_domain.device_bla_entity_blu"), + (True, None, "test_domain.device_bla"), + ), +) +async def test_entity_name_influences_entity_id( + hass, has_entity_name, entity_name, expected_entity_id +): + """Test entity_id is influenced by entity name.""" + registry = er.async_get(hass) + + async def async_setup_entry(hass, config_entry, async_add_entities): + """Mock setup entry method.""" + async_add_entities( + [ + MockEntity( + unique_id="qwer", + device_info={ + "identifiers": {("hue", "1234")}, + "connections": {(dr.CONNECTION_NETWORK_MAC, "abcd")}, + "name": "Device Bla", + }, + has_entity_name=has_entity_name, + name=entity_name, + ), + ] + ) + return True + + platform = MockPlatform(async_setup_entry=async_setup_entry) + config_entry = MockConfigEntry(entry_id="super-mock-id") + entity_platform = MockEntityPlatform( + hass, platform_name=config_entry.domain, platform=platform + ) + + assert await entity_platform.async_setup_entry(config_entry) + await hass.async_block_till_done() + + assert len(hass.states.async_entity_ids()) == 1 + assert registry.async_get(expected_entity_id) is not None diff --git a/tests/helpers/test_entity_registry.py b/tests/helpers/test_entity_registry.py index 8f5b4a7d333..ba69a98d5a8 100644 --- a/tests/helpers/test_entity_registry.py +++ b/tests/helpers/test_entity_registry.py @@ -80,6 +80,7 @@ def test_get_or_create_updates_data(registry): disabled_by=er.RegistryEntryDisabler.HASS, entity_category=EntityCategory.CONFIG, hidden_by=er.RegistryEntryHider.INTEGRATION, + has_entity_name=True, original_device_class="mock-device-class", original_icon="initial-original_icon", original_name="initial-original_name", @@ -101,6 +102,7 @@ def test_get_or_create_updates_data(registry): hidden_by=er.RegistryEntryHider.INTEGRATION, icon=None, id=orig_entry.id, + has_entity_name=True, name=None, original_device_class="mock-device-class", original_icon="initial-original_icon", @@ -122,6 +124,7 @@ def test_get_or_create_updates_data(registry): disabled_by=er.RegistryEntryDisabler.USER, entity_category=None, hidden_by=er.RegistryEntryHider.USER, + has_entity_name=False, original_device_class="new-mock-device-class", original_icon="updated-original_icon", original_name="updated-original_name", @@ -143,6 +146,7 @@ def test_get_or_create_updates_data(registry): hidden_by=er.RegistryEntryHider.INTEGRATION, # Should not be updated icon=None, id=orig_entry.id, + has_entity_name=False, name=None, original_device_class="new-mock-device-class", original_icon="updated-original_icon", @@ -196,6 +200,7 @@ async def test_loading_saving_data(hass, registry): disabled_by=er.RegistryEntryDisabler.HASS, entity_category=EntityCategory.CONFIG, hidden_by=er.RegistryEntryHider.INTEGRATION, + has_entity_name=True, original_device_class="mock-device-class", original_icon="hass:original-icon", original_name="Original Name", @@ -237,6 +242,7 @@ async def test_loading_saving_data(hass, registry): assert new_entry2.entity_category == "config" assert new_entry2.icon == "hass:user-icon" assert new_entry2.hidden_by == er.RegistryEntryHider.INTEGRATION + assert new_entry2.has_entity_name is True assert new_entry2.name == "User Name" assert new_entry2.options == {"light": {"minimum_brightness": 20}} assert new_entry2.original_device_class == "mock-device-class"