mirror of
https://github.com/home-assistant/core.git
synced 2025-07-20 11:47:06 +00:00
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:
parent
9b9700c75f
commit
b336095239
@ -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
|
||||||
|
|
||||||
|
@ -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,
|
||||||
)
|
)
|
||||||
|
@ -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(
|
||||||
|
@ -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",
|
||||||
|
@ -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(
|
||||||
|
@ -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:
|
||||||
|
@ -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(
|
||||||
{
|
{
|
||||||
|
@ -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,
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
Loading…
x
Reference in New Issue
Block a user