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(
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

View File

@ -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,
)

View File

@ -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(

View File

@ -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",

View File

@ -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(

View File

@ -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:

View File

@ -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(
{

View File

@ -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,

View File

@ -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

View File

@ -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