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
This commit is contained in:
Erik Montnemery 2024-02-28 15:59:59 +01:00 committed by GitHub
parent 9b9700c75f
commit b336095239
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 268 additions and 17 deletions

View File

@ -21,7 +21,9 @@ async def async_setup_entry(
[ [
DemoButton( DemoButton(
unique_id="2_ch_power_strip", 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", entity_name="Restart",
), ),
] ]
@ -37,7 +39,9 @@ class DemoButton(ButtonEntity):
def __init__( def __init__(
self, self,
unique_id: str, 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, entity_name: str | None,
) -> None: ) -> None:
"""Initialize the Demo button entity.""" """Initialize the Demo button entity."""
@ -45,6 +49,8 @@ class DemoButton(ButtonEntity):
self._attr_device_info = DeviceInfo( self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, unique_id)}, identifiers={(DOMAIN, unique_id)},
name=device_name, name=device_name,
translation_key=device_translation_key,
translation_placeholders=device_translation_placeholders,
) )
self._attr_name = entity_name self._attr_name = entity_name

View File

@ -12,12 +12,16 @@ def async_create_device(
hass: HomeAssistant, hass: HomeAssistant,
config_entry_id: str, config_entry_id: str,
device_name: str | None, device_name: str | None,
device_translation_key: str | None,
device_translation_placeholders: dict[str, str] | None,
unique_id: str, unique_id: str,
) -> dr.DeviceEntry: ) -> dr.DeviceEntry:
"""Create a device.""" """Create a device."""
device_registry = dr.async_get(hass) device_registry = dr.async_get(hass)
return device_registry.async_get_or_create( return device_registry.async_get_or_create(
config_entry_id=config_entry_id, config_entry_id=config_entry_id,
name=device_name,
identifiers={(DOMAIN, unique_id)}, identifiers={(DOMAIN, unique_id)},
name=device_name,
translation_key=device_translation_key,
translation_placeholders=device_translation_placeholders,
) )

View File

@ -24,7 +24,12 @@ async def async_setup_entry(
) -> None: ) -> None:
"""Set up the Everything but the Kitchen Sink config entry.""" """Set up the Everything but the Kitchen Sink config entry."""
async_create_device( 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( async_add_entities(

View File

@ -6,6 +6,11 @@
} }
} }
}, },
"device": {
"n_ch_power_strip": {
"name": "Power strip with {number_of_sockets} sockets"
}
},
"issues": { "issues": {
"bad_psu": { "bad_psu": {
"title": "The power supply is not stable", "title": "The power supply is not stable",

View File

@ -20,7 +20,12 @@ async def async_setup_entry(
) -> None: ) -> None:
"""Set up the demo switch platform.""" """Set up the demo switch platform."""
async_create_device( 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( async_add_entities(

View File

@ -2,7 +2,7 @@
from __future__ import annotations from __future__ import annotations
from collections import UserDict from collections import UserDict
from collections.abc import ValuesView from collections.abc import Mapping, ValuesView
from enum import StrEnum from enum import StrEnum
from functools import lru_cache, partial from functools import lru_cache, partial
import logging import logging
@ -15,12 +15,13 @@ from yarl import URL
from homeassistant.backports.functools import cached_property from homeassistant.backports.functools import cached_property
from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP 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.exceptions import HomeAssistantError
from homeassistant.loader import async_suggest_report_issue
from homeassistant.util.json import format_unserializable_data from homeassistant.util.json import format_unserializable_data
import homeassistant.util.uuid as uuid_util import homeassistant.util.uuid as uuid_util
from . import storage from . import storage, translation
from .debounce import Debouncer from .debounce import Debouncer
from .deprecation import ( from .deprecation import (
DeprecatedConstantEnum, DeprecatedConstantEnum,
@ -94,6 +95,8 @@ class DeviceInfo(TypedDict, total=False):
suggested_area: str | None suggested_area: str | None
sw_version: str | None sw_version: str | None
hw_version: str | None hw_version: str | None
translation_key: str | None
translation_placeholders: Mapping[str, str] | None
via_device: tuple[str, str] via_device: tuple[str, str]
@ -497,6 +500,33 @@ class DeviceRegistry:
"""Check if device is deleted.""" """Check if device is deleted."""
return self.deleted_devices.get_entry(identifiers, connections) 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 @callback
def async_get_or_create( def async_get_or_create(
self, self,
@ -518,12 +548,32 @@ class DeviceRegistry:
serial_number: str | None | UndefinedType = UNDEFINED, serial_number: str | None | UndefinedType = UNDEFINED,
suggested_area: str | None | UndefinedType = UNDEFINED, suggested_area: str | None | UndefinedType = UNDEFINED,
sw_version: 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, via_device: tuple[str, str] | None | UndefinedType = UNDEFINED,
) -> DeviceEntry: ) -> DeviceEntry:
"""Get device. Create if it doesn't exist.""" """Get device. Create if it doesn't exist."""
if configuration_url is not UNDEFINED: if configuration_url is not UNDEFINED:
configuration_url = _validate_configuration_url(configuration_url) 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. # Reconstruct a DeviceInfo dict from the arguments.
# When we upgrade to Python 3.12, we can change this method to instead # When we upgrade to Python 3.12, we can change this method to instead
# accept kwargs typed as a DeviceInfo dict (PEP 692) # accept kwargs typed as a DeviceInfo dict (PEP 692)
@ -549,11 +599,6 @@ class DeviceRegistry:
continue continue
device_info[key] = val # type: ignore[literal-required] 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) device_info_type = _validate_device_info(config_entry, device_info)
if identifiers is None or identifiers is UNDEFINED: if identifiers is None or identifiers is UNDEFINED:

View File

@ -311,6 +311,12 @@ def gen_strings_schema(config: Config, integration: Integration) -> vol.Schema:
}, },
slug_validator=vol.Any("_", cv.slug), 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( vol.Optional("entity"): cv.schema_with_slug_keys(
cv.schema_with_slug_keys( cv.schema_with_slug_keys(
{ {

View File

@ -96,7 +96,7 @@
}), }),
'manufacturer': None, 'manufacturer': None,
'model': None, 'model': None,
'name': '2CH Power strip', 'name': 'Power strip with 2 sockets',
'name_by_user': None, 'name_by_user': None,
'serial_number': None, 'serial_number': None,
'suggested_area': None, 'suggested_area': None,
@ -201,7 +201,7 @@
}), }),
'manufacturer': None, 'manufacturer': None,
'model': None, 'model': None,
'name': '2CH Power strip', 'name': 'Power strip with 2 sockets',
'name_by_user': None, 'name_by_user': None,
'serial_number': None, 'serial_number': None,
'suggested_area': None, 'suggested_area': None,

View File

@ -12,7 +12,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component from homeassistant.setup import async_setup_component
from homeassistant.util import dt as dt_util 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 @pytest.fixture

View File

@ -1,5 +1,6 @@
"""Tests for the Device Registry.""" """Tests for the Device Registry."""
from contextlib import nullcontext from collections.abc import Iterable
from contextlib import AbstractContextManager, nullcontext
import time import time
from typing import Any from typing import Any
from unittest.mock import patch 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, "unknown")
assert not dr.async_entries_for_label(device_registry, "") 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