From b33609523958cbe50115a9aed15a150489d6fd65 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 28 Feb 2024 15:59:59 +0100 Subject: [PATCH] Allow translating device names (#110711) * Allow translating device names * Don't keep a reference to translations in config entry * Update kitchen_sink tests * Add tests --- .../components/kitchen_sink/button.py | 10 +- .../components/kitchen_sink/device.py | 6 +- .../components/kitchen_sink/sensor.py | 7 +- .../components/kitchen_sink/strings.json | 5 + .../components/kitchen_sink/switch.py | 7 +- homeassistant/helpers/device_registry.py | 61 +++++- script/hassfest/translations.py | 6 + .../kitchen_sink/snapshots/test_switch.ambr | 4 +- tests/components/kitchen_sink/test_button.py | 2 +- tests/helpers/test_device_registry.py | 177 +++++++++++++++++- 10 files changed, 268 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/kitchen_sink/button.py b/homeassistant/components/kitchen_sink/button.py index 1a8da80983f..cdc0cebb348 100644 --- a/homeassistant/components/kitchen_sink/button.py +++ b/homeassistant/components/kitchen_sink/button.py @@ -21,7 +21,9 @@ async def async_setup_entry( [ DemoButton( unique_id="2_ch_power_strip", - device_name="2CH Power strip", + device_name=None, + device_translation_key="n_ch_power_strip", + device_translation_placeholders={"number_of_sockets": "2"}, entity_name="Restart", ), ] @@ -37,7 +39,9 @@ class DemoButton(ButtonEntity): def __init__( self, unique_id: str, - device_name: str, + device_name: str | None, + device_translation_key: str | None, + device_translation_placeholders: dict[str, str] | None, entity_name: str | None, ) -> None: """Initialize the Demo button entity.""" @@ -45,6 +49,8 @@ class DemoButton(ButtonEntity): self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, unique_id)}, name=device_name, + translation_key=device_translation_key, + translation_placeholders=device_translation_placeholders, ) self._attr_name = entity_name diff --git a/homeassistant/components/kitchen_sink/device.py b/homeassistant/components/kitchen_sink/device.py index 295e7869ec4..fef41f7917c 100644 --- a/homeassistant/components/kitchen_sink/device.py +++ b/homeassistant/components/kitchen_sink/device.py @@ -12,12 +12,16 @@ def async_create_device( hass: HomeAssistant, config_entry_id: str, device_name: str | None, + device_translation_key: str | None, + device_translation_placeholders: dict[str, str] | None, unique_id: str, ) -> dr.DeviceEntry: """Create a device.""" device_registry = dr.async_get(hass) return device_registry.async_get_or_create( config_entry_id=config_entry_id, - name=device_name, identifiers={(DOMAIN, unique_id)}, + name=device_name, + translation_key=device_translation_key, + translation_placeholders=device_translation_placeholders, ) diff --git a/homeassistant/components/kitchen_sink/sensor.py b/homeassistant/components/kitchen_sink/sensor.py index a14c4a26e4e..4800104d17d 100644 --- a/homeassistant/components/kitchen_sink/sensor.py +++ b/homeassistant/components/kitchen_sink/sensor.py @@ -24,7 +24,12 @@ async def async_setup_entry( ) -> None: """Set up the Everything but the Kitchen Sink config entry.""" async_create_device( - hass, config_entry.entry_id, "2CH Power strip", "2_ch_power_strip" + hass, + config_entry.entry_id, + None, + "n_ch_power_strip", + {"number_of_sockets": "2"}, + "2_ch_power_strip", ) async_add_entities( diff --git a/homeassistant/components/kitchen_sink/strings.json b/homeassistant/components/kitchen_sink/strings.json index dca42ce8361..ecfbe406aab 100644 --- a/homeassistant/components/kitchen_sink/strings.json +++ b/homeassistant/components/kitchen_sink/strings.json @@ -6,6 +6,11 @@ } } }, + "device": { + "n_ch_power_strip": { + "name": "Power strip with {number_of_sockets} sockets" + } + }, "issues": { "bad_psu": { "title": "The power supply is not stable", diff --git a/homeassistant/components/kitchen_sink/switch.py b/homeassistant/components/kitchen_sink/switch.py index 4329be8b9d7..e60de2f09c8 100644 --- a/homeassistant/components/kitchen_sink/switch.py +++ b/homeassistant/components/kitchen_sink/switch.py @@ -20,7 +20,12 @@ async def async_setup_entry( ) -> None: """Set up the demo switch platform.""" async_create_device( - hass, config_entry.entry_id, "2CH Power strip", "2_ch_power_strip" + hass, + config_entry.entry_id, + None, + "n_ch_power_strip", + {"number_of_sockets": "2"}, + "2_ch_power_strip", ) async_add_entities( diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index 33f12642cea..826a4cc200e 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -2,7 +2,7 @@ from __future__ import annotations from collections import UserDict -from collections.abc import ValuesView +from collections.abc import Mapping, ValuesView from enum import StrEnum from functools import lru_cache, partial import logging @@ -15,12 +15,13 @@ from yarl import URL from homeassistant.backports.functools import cached_property from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP -from homeassistant.core import Event, HomeAssistant, callback +from homeassistant.core import Event, HomeAssistant, callback, get_release_channel from homeassistant.exceptions import HomeAssistantError +from homeassistant.loader import async_suggest_report_issue from homeassistant.util.json import format_unserializable_data import homeassistant.util.uuid as uuid_util -from . import storage +from . import storage, translation from .debounce import Debouncer from .deprecation import ( DeprecatedConstantEnum, @@ -94,6 +95,8 @@ class DeviceInfo(TypedDict, total=False): suggested_area: str | None sw_version: str | None hw_version: str | None + translation_key: str | None + translation_placeholders: Mapping[str, str] | None via_device: tuple[str, str] @@ -497,6 +500,33 @@ class DeviceRegistry: """Check if device is deleted.""" return self.deleted_devices.get_entry(identifiers, connections) + def _substitute_name_placeholders( + self, + domain: str, + name: str, + translation_placeholders: Mapping[str, str], + ) -> str: + """Substitute placeholders in entity name.""" + try: + return name.format(**translation_placeholders) + except KeyError as err: + if get_release_channel() != "stable": + raise HomeAssistantError("Missing placeholder %s" % err) from err + report_issue = async_suggest_report_issue( + self.hass, integration_domain=domain + ) + _LOGGER.warning( + ( + "Device from integration %s has translation placeholders '%s' " + "which do not match the name '%s', please %s" + ), + domain, + translation_placeholders, + name, + report_issue, + ) + return name + @callback def async_get_or_create( self, @@ -518,12 +548,32 @@ class DeviceRegistry: serial_number: str | None | UndefinedType = UNDEFINED, suggested_area: str | None | UndefinedType = UNDEFINED, sw_version: str | None | UndefinedType = UNDEFINED, + translation_key: str | None = None, + translation_placeholders: Mapping[str, str] | None = None, via_device: tuple[str, str] | None | UndefinedType = UNDEFINED, ) -> DeviceEntry: """Get device. Create if it doesn't exist.""" if configuration_url is not UNDEFINED: configuration_url = _validate_configuration_url(configuration_url) + config_entry = self.hass.config_entries.async_get_entry(config_entry_id) + if config_entry is None: + raise HomeAssistantError( + f"Can't link device to unknown config entry {config_entry_id}" + ) + + if translation_key: + full_translation_key = ( + f"component.{config_entry.domain}.device.{translation_key}.name" + ) + translations = translation.async_get_cached_translations( + self.hass, self.hass.config.language, "device", config_entry.domain + ) + translated_name = translations.get(full_translation_key, translation_key) + name = self._substitute_name_placeholders( + config_entry.domain, translated_name, translation_placeholders or {} + ) + # Reconstruct a DeviceInfo dict from the arguments. # When we upgrade to Python 3.12, we can change this method to instead # accept kwargs typed as a DeviceInfo dict (PEP 692) @@ -549,11 +599,6 @@ class DeviceRegistry: continue device_info[key] = val # type: ignore[literal-required] - config_entry = self.hass.config_entries.async_get_entry(config_entry_id) - if config_entry is None: - raise HomeAssistantError( - f"Can't link device to unknown config entry {config_entry_id}" - ) device_info_type = _validate_device_info(config_entry, device_info) if identifiers is None or identifiers is UNDEFINED: diff --git a/script/hassfest/translations.py b/script/hassfest/translations.py index 738ebcb260a..1501e4983c5 100644 --- a/script/hassfest/translations.py +++ b/script/hassfest/translations.py @@ -311,6 +311,12 @@ def gen_strings_schema(config: Config, integration: Integration) -> vol.Schema: }, slug_validator=vol.Any("_", cv.slug), ), + vol.Optional("device"): cv.schema_with_slug_keys( + { + vol.Optional("name"): translation_value_validator, + }, + slug_validator=translation_key_validator, + ), vol.Optional("entity"): cv.schema_with_slug_keys( cv.schema_with_slug_keys( { diff --git a/tests/components/kitchen_sink/snapshots/test_switch.ambr b/tests/components/kitchen_sink/snapshots/test_switch.ambr index b95bc551a56..89688e9e54e 100644 --- a/tests/components/kitchen_sink/snapshots/test_switch.ambr +++ b/tests/components/kitchen_sink/snapshots/test_switch.ambr @@ -96,7 +96,7 @@ }), 'manufacturer': None, 'model': None, - 'name': '2CH Power strip', + 'name': 'Power strip with 2 sockets', 'name_by_user': None, 'serial_number': None, 'suggested_area': None, @@ -201,7 +201,7 @@ }), 'manufacturer': None, 'model': None, - 'name': '2CH Power strip', + 'name': 'Power strip with 2 sockets', 'name_by_user': None, 'serial_number': None, 'suggested_area': None, diff --git a/tests/components/kitchen_sink/test_button.py b/tests/components/kitchen_sink/test_button.py index 3f49f814c92..bad072d9d46 100644 --- a/tests/components/kitchen_sink/test_button.py +++ b/tests/components/kitchen_sink/test_button.py @@ -12,7 +12,7 @@ from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util -ENTITY_RESTART = "button.2ch_power_strip_restart" +ENTITY_RESTART = "button.power_strip_with_2_sockets_restart" @pytest.fixture diff --git a/tests/helpers/test_device_registry.py b/tests/helpers/test_device_registry.py index f317e7b5209..cda117cca26 100644 --- a/tests/helpers/test_device_registry.py +++ b/tests/helpers/test_device_registry.py @@ -1,5 +1,6 @@ """Tests for the Device Registry.""" -from contextlib import nullcontext +from collections.abc import Iterable +from contextlib import AbstractContextManager, nullcontext import time from typing import Any from unittest.mock import patch @@ -2293,3 +2294,177 @@ async def test_entries_for_label( assert not dr.async_entries_for_label(device_registry, "unknown") assert not dr.async_entries_for_label(device_registry, "") + + +@pytest.mark.parametrize( + ( + "translation_key", + "translations", + "placeholders", + "expected_device_name", + ), + ( + (None, None, None, "Device Bla"), + ( + "test_device", + { + "en": {"component.test.device.test_device.name": "English device"}, + }, + None, + "English device", + ), + ( + "test_device", + { + "en": { + "component.test.device.test_device.name": "{placeholder} English dev" + }, + }, + {"placeholder": "special"}, + "special English dev", + ), + ( + "test_device", + { + "en": { + "component.test.device.test_device.name": "English dev {placeholder}" + }, + }, + {"placeholder": "special"}, + "English dev special", + ), + ), +) +async def test_device_name_translation_placeholders( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + translation_key: str | None, + translations: dict[str, str] | None, + placeholders: dict[str, str] | None, + expected_device_name: str | None, +) -> None: + """Test device name when the device name translation has placeholders.""" + + def async_get_cached_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] + + config_entry_1 = MockConfigEntry() + config_entry_1.add_to_hass(hass) + with patch( + "homeassistant.helpers.device_registry.translation.async_get_cached_translations", + side_effect=async_get_cached_translations, + ): + entry1 = device_registry.async_get_or_create( + config_entry_id=config_entry_1.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + name="Device Bla", + translation_key=translation_key, + translation_placeholders=placeholders, + ) + assert entry1.name == expected_device_name + + +@pytest.mark.parametrize( + ( + "translation_key", + "translations", + "placeholders", + "release_channel", + "expectation", + "expected_error", + ), + ( + ( + "test_device", + { + "en": { + "component.test.device.test_device.name": "{placeholder} English dev {2ndplaceholder}" + }, + }, + {"placeholder": "special"}, + "stable", + nullcontext(), + ( + "has translation placeholders '{'placeholder': 'special'}' which do " + "not match the name '{placeholder} English dev {2ndplaceholder}'" + ), + ), + ( + "test_device", + { + "en": { + "component.test.device.test_device.name": "{placeholder} English ent {2ndplaceholder}" + }, + }, + {"placeholder": "special"}, + "beta", + pytest.raises( + HomeAssistantError, match="Missing placeholder '2ndplaceholder'" + ), + "", + ), + ( + "test_device", + { + "en": { + "component.test.device.test_device.name": "{placeholder} English dev" + }, + }, + None, + "stable", + nullcontext(), + ( + "has translation placeholders '{}' which do " + "not match the name '{placeholder} English dev'" + ), + ), + ), +) +async def test_device_name_translation_placeholders_errors( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + translation_key: str | None, + translations: dict[str, str] | None, + placeholders: dict[str, str] | None, + release_channel: str, + expectation: AbstractContextManager, + expected_error: str, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test device name has placeholder issuess.""" + + def async_get_cached_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] + + config_entry_1 = MockConfigEntry() + config_entry_1.add_to_hass(hass) + with patch( + "homeassistant.helpers.device_registry.translation.async_get_cached_translations", + side_effect=async_get_cached_translations, + ), patch( + "homeassistant.helpers.device_registry.get_release_channel", + return_value=release_channel, + ), expectation: + device_registry.async_get_or_create( + config_entry_id=config_entry_1.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + name="Device Bla", + translation_key=translation_key, + translation_placeholders=placeholders, + ) + + assert expected_error in caplog.text