diff --git a/homeassistant/generated/languages.py b/homeassistant/generated/languages.py index b4aebb0f1a4..feedd373fd9 100644 --- a/homeassistant/generated/languages.py +++ b/homeassistant/generated/languages.py @@ -3,6 +3,8 @@ To update, run python3 -m script.languages [frontend_tag] """ +DEFAULT_LANGUAGE = "en" + LANGUAGES = { "af", "ar", @@ -66,3 +68,46 @@ LANGUAGES = { "zh-Hans", "zh-Hant", } + +NATIVE_ENTITY_IDS = { + "af", + "bs", + "ca", + "cs", + "cy", + "da", + "de", + "en", + "en-GB", + "eo", + "es", + "es-419", + "et", + "eu", + "fi", + "fr", + "fy", + "gl", + "gsw", + "hr", + "hu", + "id", + "is", + "it", + "ka", + "lb", + "lt", + "lv", + "nb", + "nl", + "nn", + "pl", + "pt", + "pt-BR", + "ro", + "sk", + "sl", + "sr-Latn", + "sv", + "tr", +} diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index d6f2574f388..3d22e2538a3 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -381,17 +381,31 @@ class Entity(ABC): return self.entity_description.has_entity_name return False - @cached_property - def _device_class_name(self) -> str | None: + def _device_class_name_helper( + self, + component_translations: dict[str, Any], + ) -> str | None: """Return a translated name of the entity based on its device class.""" if not self.has_entity_name: return None device_class_key = self.device_class or "_" platform = self.platform name_translation_key = ( - f"component.{platform.domain}.entity_component." f"{device_class_key}.name" + f"component.{platform.domain}.entity_component.{device_class_key}.name" ) - return platform.component_translations.get(name_translation_key) + return component_translations.get(name_translation_key) + + @cached_property + def _object_id_device_class_name(self) -> str | None: + """Return a translated name of the entity based on its device class.""" + return self._device_class_name_helper( + self.platform.object_id_component_translations + ) + + @cached_property + def _device_class_name(self) -> str | None: + """Return a translated name of the entity based on its device class.""" + return self._device_class_name_helper(self.platform.component_translations) def _default_to_device_class_name(self) -> bool: """Return True if an unnamed entity should be named by its device class.""" @@ -408,15 +422,18 @@ class Entity(ABC): f".{self.translation_key}.name" ) - @property - def name(self) -> str | UndefinedType | None: + def _name_internal( + self, + device_class_name: str | None, + platform_translations: dict[str, Any], + ) -> str | UndefinedType | None: """Return the name of the entity.""" if hasattr(self, "_attr_name"): return self._attr_name if ( self.has_entity_name and (name_translation_key := self._name_translation_key) - and (name := self.platform.platform_translations.get(name_translation_key)) + and (name := platform_translations.get(name_translation_key)) ): if TYPE_CHECKING: assert isinstance(name, str) @@ -424,15 +441,42 @@ class Entity(ABC): if hasattr(self, "entity_description"): description_name = self.entity_description.name if description_name is UNDEFINED and self._default_to_device_class_name(): - return self._device_class_name + return device_class_name return description_name # The entity has no name set by _attr_name, translation_key or entity_description # Check if the entity should be named by its device class if self._default_to_device_class_name(): - return self._device_class_name + return device_class_name return UNDEFINED + @property + def suggested_object_id(self) -> str | None: + """Return input for object id.""" + # The check for self.platform guards against integrations not using an + # EntityComponent and can be removed in HA Core 2024.1 + # mypy doesn't know about fget: https://github.com/python/mypy/issues/6185 + if self.__class__.name.fget is Entity.name.fget and self.platform: # type: ignore[attr-defined] + name = self._name_internal( + self._object_id_device_class_name, + self.platform.object_id_platform_translations, + ) + else: + name = self.name + return None if name is UNDEFINED else name + + @property + def name(self) -> str | UndefinedType | None: + """Return the name of the entity.""" + # The check for self.platform guards against integrations not using an + # EntityComponent and can be removed in HA Core 2024.1 + if not self.platform: + return self._name_internal(None, {}) + return self._name_internal( + self._device_class_name, + self.platform.platform_translations, + ) + @property def state(self) -> StateType: """Return the state of the entity.""" diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index 46cc46eb96f..45f8f15c9df 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -31,6 +31,7 @@ from homeassistant.exceptions import ( PlatformNotReady, RequiredParameterMissing, ) +from homeassistant.generated import languages from homeassistant.setup import async_start_setup from homeassistant.util.async_ import run_callback_threadsafe @@ -128,6 +129,8 @@ class EntityPlatform: self.entities: dict[str, Entity] = {} self.component_translations: dict[str, Any] = {} self.platform_translations: dict[str, Any] = {} + self.object_id_component_translations: dict[str, Any] = {} + self.object_id_platform_translations: dict[str, Any] = {} self._tasks: list[asyncio.Task[None]] = [] # Stop tracking tasks after setup is completed self._setup_complete = False @@ -294,22 +297,43 @@ class EntityPlatform: logger = self.logger hass = self.hass full_name = f"{self.domain}.{self.platform_name}" + object_id_language = ( + hass.config.language + if hass.config.language in languages.NATIVE_ENTITY_IDS + else languages.DEFAULT_LANGUAGE + ) - try: - self.component_translations = await translation.async_get_translations( - hass, hass.config.language, "entity_component", {self.domain} + async def get_translations( + language: str, category: str, integration: str + ) -> dict[str, Any]: + """Get entity translations.""" + try: + return await translation.async_get_translations( + hass, language, category, {integration} + ) + except Exception as err: # pylint: disable=broad-exception-caught + _LOGGER.debug( + "Could not load translations for %s", + integration, + exc_info=err, + ) + return {} + + self.component_translations = await get_translations( + hass.config.language, "entity_component", self.domain + ) + self.platform_translations = await get_translations( + hass.config.language, "entity", self.platform_name + ) + if object_id_language == hass.config.language: + self.object_id_component_translations = self.component_translations + self.object_id_platform_translations = self.platform_translations + else: + self.object_id_component_translations = await get_translations( + object_id_language, "entity_component", self.domain ) - except Exception as err: # pylint: disable=broad-exception-caught - _LOGGER.debug( - "Could not load translations for %s", self.domain, exc_info=err - ) - try: - self.platform_translations = await translation.async_get_translations( - hass, hass.config.language, "entity", {self.platform_name} - ) - except Exception as err: # pylint: disable=broad-exception-caught - _LOGGER.debug( - "Could not load translations for %s", self.platform_name, exc_info=err + self.object_id_platform_translations = await get_translations( + object_id_language, "entity", self.platform_name ) logger.info("Setting up %s", full_name) @@ -652,9 +676,11 @@ class EntityPlatform: if entity.use_device_name: suggested_object_id = device_name else: - suggested_object_id = f"{device_name} {entity_name}" + suggested_object_id = ( + f"{device_name} {entity.suggested_object_id}" + ) if not suggested_object_id: - suggested_object_id = entity_name + suggested_object_id = entity.suggested_object_id if self.entity_namespace is not None: suggested_object_id = f"{self.entity_namespace} {suggested_object_id}" @@ -709,7 +735,7 @@ class EntityPlatform: # Generate entity ID if entity.entity_id is None or generate_new_entity_id: suggested_object_id = ( - suggested_object_id or entity_name or DEVICE_DEFAULT_NAME + suggested_object_id or entity.suggested_object_id or DEVICE_DEFAULT_NAME ) if self.entity_namespace is not None: diff --git a/script/languages.py b/script/languages.py index ad88a31b0b6..f55fc21db93 100644 --- a/script/languages.py +++ b/script/languages.py @@ -15,10 +15,61 @@ req = requests.get( data = json.loads(req.content) languages = set(data.keys()) +# Languages which can be used for entity IDs. +# Languages in the set are those which use a writing system based on the Latin +# script. Languages not in this set will instead base the entity ID on English. + +# Note: Although vietnamese writing is based on the Latin script, it's too ambiguous +# after accents and diacritics have been removed by slugify +NATIVE_ENTITY_IDS = { + "af", # Afrikaans + "bs", # Bosanski + "ca", # Català + "cs", # Čeština + "cy", # Cymraeg + "da", # Dansk + "de", # Deutsch + "en", # English + "en-GB", # English (GB) + "eo", # Esperanto + "es", # Español + "es-419", # Español (Latin America) + "et", # Eesti + "eu", # Euskara + "fi", # Suomi + "fr", # Français + "fy", # Frysk + "gl", # Galego + "gsw", # Schwiizerdütsch + "hr", # Hrvatski + "hu", # Magyar + "id", # Indonesia + "is", # Íslenska + "it", # Italiano + "ka", # Kartuli + "lb", # Lëtzebuergesch + "lt", # Lietuvių + "lv", # Latviešu + "nb", # Nederlands + "nl", # Norsk Bokmål + "nn", # Norsk Nynorsk" + "pl", # Polski + "pt", # Português + "pt-BR", # Português (BR) + "ro", # Română + "sk", # Slovenčina + "sl", # Slovenščina + "sr-Latn", # Srpski + "sv", # Svenska + "tr", # Türkçe +} + Path("homeassistant/generated/languages.py").write_text( format_python_namespace( { + "DEFAULT_LANGUAGE": "en", "LANGUAGES": languages, + "NATIVE_ENTITY_IDS": NATIVE_ENTITY_IDS, }, generator="script.languages [frontend_tag]", ) diff --git a/tests/helpers/test_entity_platform.py b/tests/helpers/test_entity_platform.py index dd39f664f7a..46806510f40 100644 --- a/tests/helpers/test_entity_platform.py +++ b/tests/helpers/test_entity_platform.py @@ -1,5 +1,6 @@ """Tests for the EntityPlatform helper.""" import asyncio +from collections.abc import Iterable from datetime import timedelta import logging from typing import Any @@ -18,6 +19,7 @@ from homeassistant.helpers import ( ) from homeassistant.helpers.entity import ( DeviceInfo, + Entity, EntityCategory, async_generate_entity_id, ) @@ -1669,3 +1671,159 @@ async def test_entity_name_influences_entity_id( assert len(hass.states.async_entity_ids()) == 1 assert registry.async_get(expected_entity_id) is not None + + +@pytest.mark.parametrize( + ("language", "has_entity_name", "expected_entity_id"), + ( + ("en", False, "test_domain.test_qwer"), # Set to _ + ("en", True, "test_domain.device_bla_english_name"), + ("sv", True, "test_domain.device_bla_swedish_name"), + # Chinese uses english for entity_id + ("cn", True, "test_domain.device_bla_english_name"), + ), +) +async def test_translated_entity_name_influences_entity_id( + hass: HomeAssistant, + language: str, + has_entity_name: bool, + expected_entity_id: str, +) -> None: + """Test entity_id is influenced by translated entity name.""" + + class TranslatedEntity(Entity): + _attr_unique_id = "qwer" + _attr_device_info = { + "identifiers": {("hue", "1234")}, + "connections": {(dr.CONNECTION_NETWORK_MAC, "abcd")}, + "name": "Device Bla", + } + + _attr_translation_key = "test" + + def __init__(self, has_entity_name: bool) -> None: + """Initialize.""" + self._attr_has_entity_name = has_entity_name + + registry = er.async_get(hass) + + translations = { + "en": {"component.test.entity.test_domain.test.name": "English name"}, + "sv": {"component.test.entity.test_domain.test.name": "Swedish name"}, + "cn": {"component.test.entity.test_domain.test.name": "Chinese name"}, + } + hass.config.language = language + + async def async_get_translations( + hass: HomeAssistant, + language: str, + category: str, + integrations: Iterable[str] | None = None, + config_flow: bool | None = None, + ) -> dict[str, Any]: + """Return all backend translations.""" + return translations[language] + + async def async_setup_entry(hass, config_entry, async_add_entities): + """Mock setup entry method.""" + async_add_entities([TranslatedEntity(has_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 + ) + + with patch( + "homeassistant.helpers.entity_platform.translation.async_get_translations", + side_effect=async_get_translations, + ): + 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 + + +@pytest.mark.parametrize( + ("language", "has_entity_name", "device_class", "expected_entity_id"), + ( + ("en", False, None, "test_domain.test_qwer"), # Set to _ + ( + "en", + False, + "test_class", + "test_domain.test_qwer", + ), # Set to _ + ("en", True, "test_class", "test_domain.device_bla_english_cls"), + ("sv", True, "test_class", "test_domain.device_bla_swedish_cls"), + # Chinese uses english for entity_id + ("cn", True, "test_class", "test_domain.device_bla_english_cls"), + ), +) +async def test_translated_device_class_name_influences_entity_id( + hass: HomeAssistant, + language: str, + has_entity_name: bool, + device_class: str | None, + expected_entity_id: str, +) -> None: + """Test entity_id is influenced by translated entity name.""" + + class TranslatedDeviceClassEntity(Entity): + _attr_unique_id = "qwer" + _attr_device_info = { + "identifiers": {("hue", "1234")}, + "connections": {(dr.CONNECTION_NETWORK_MAC, "abcd")}, + "name": "Device Bla", + } + + def __init__(self, device_class: str | None, has_entity_name: bool) -> None: + """Initialize.""" + self._attr_device_class = device_class + self._attr_has_entity_name = has_entity_name + + def _default_to_device_class_name(self) -> bool: + """Return True if an unnamed entity should be named by its device class.""" + return self.device_class is not None + + registry = er.async_get(hass) + + translations = { + "en": {"component.test_domain.entity_component.test_class.name": "English cls"}, + "sv": {"component.test_domain.entity_component.test_class.name": "Swedish cls"}, + "cn": {"component.test_domain.entity_component.test_class.name": "Chinese cls"}, + } + hass.config.language = language + + async def async_get_translations( + hass: HomeAssistant, + language: str, + category: str, + integrations: Iterable[str] | None = None, + config_flow: bool | None = None, + ) -> dict[str, Any]: + """Return all backend translations.""" + return translations[language] + + async def async_setup_entry(hass, config_entry, async_add_entities): + """Mock setup entry method.""" + async_add_entities([TranslatedDeviceClassEntity(device_class, has_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 + ) + + with patch( + "homeassistant.helpers.entity_platform.translation.async_get_translations", + side_effect=async_get_translations, + ): + 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