mirror of
https://github.com/home-assistant/core.git
synced 2025-07-22 04:37: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(
|
||||
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
|
||||
|
||||
|
@ -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,
|
||||
)
|
||||
|
@ -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(
|
||||
|
@ -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",
|
||||
|
@ -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(
|
||||
|
@ -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:
|
||||
|
@ -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(
|
||||
{
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
Loading…
x
Reference in New Issue
Block a user