Compare commits

...

5 Commits

Author SHA1 Message Date
Paul Bottein
113921df1b Parametrize tests 2026-03-17 14:37:56 +01:00
Paul Bottein
9f1e8ed07d Fix import order 2026-03-17 13:34:26 +01:00
Copilot
06c5f3d3d1 Fix state_attr_translated to preserve original type for non-string attributes (#165320)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: piitaya <5878303+piitaya@users.noreply.github.com>
2026-03-17 11:06:41 +01:00
Paul Bottein
c9537f7357 Merge branch 'dev' into state_attr_translated 2026-03-11 17:49:04 +01:00
Paul Bottein
57a33dd34d Add state_attr_translated template filter and function 2026-03-11 10:24:28 +01:00
4 changed files with 322 additions and 2 deletions

View File

@@ -8,6 +8,7 @@ import collections.abc
from collections.abc import Callable, Generator, Iterable
from copy import deepcopy
from datetime import datetime, timedelta
from enum import Enum
from functools import cache, lru_cache, partial, wraps
import json
import logging
@@ -57,7 +58,10 @@ from homeassistant.core import (
from homeassistant.exceptions import TemplateError
from homeassistant.helpers import entity_registry as er, location as loc_helper
from homeassistant.helpers.singleton import singleton
from homeassistant.helpers.translation import async_translate_state
from homeassistant.helpers.translation import (
async_translate_state,
async_translate_state_attr,
)
from homeassistant.helpers.typing import TemplateVarsType
from homeassistant.util import convert, location as location_util
from homeassistant.util.async_ import run_callback_threadsafe
@@ -807,6 +811,48 @@ class StateTranslated:
return "<template StateTranslated>"
class StateAttrTranslated:
"""Class to represent a translated state attribute value in a template."""
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize."""
self._hass = hass
def __call__(self, entity_id: str, attribute: str) -> Any:
"""Retrieve translated state attribute value if available."""
state = _get_state_if_valid(self._hass, entity_id)
if state is None:
return None
attr_value = state.attributes.get(attribute)
if attr_value is None:
return None
if not isinstance(attr_value, str | Enum):
return attr_value
domain = state.domain
device_class = state.attributes.get("device_class")
entry = er.async_get(self._hass).async_get(entity_id)
platform = None if entry is None else entry.platform
translation_key = None if entry is None else entry.translation_key
return async_translate_state_attr(
self._hass,
str(attr_value),
domain,
platform,
translation_key,
device_class,
attribute,
)
def __repr__(self) -> str:
"""Representation of Translated state attribute."""
return "<template StateAttrTranslated>"
class DomainStates:
"""Class to expose a specific HA domain as attributes."""
@@ -1989,6 +2035,7 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment):
"is_state_attr",
"is_state",
"state_attr",
"state_attr_translated",
"state_translated",
"states",
]
@@ -2036,9 +2083,11 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment):
self.globals["is_state_attr"] = hassfunction(is_state_attr)
self.globals["is_state"] = hassfunction(is_state)
self.globals["state_attr"] = hassfunction(state_attr)
self.globals["state_attr_translated"] = StateAttrTranslated(hass)
self.globals["state_translated"] = StateTranslated(hass)
self.globals["states"] = AllStates(hass)
self.filters["state_attr"] = self.globals["state_attr"]
self.filters["state_attr_translated"] = self.globals["state_attr_translated"]
self.filters["state_translated"] = self.globals["state_translated"]
self.filters["states"] = self.globals["states"]
self.tests["is_state_attr"] = hassfunction(is_state_attr, pass_eval_context)
@@ -2047,7 +2096,7 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment):
def is_safe_callable(self, obj):
"""Test if callback is safe."""
return isinstance(
obj, (AllStates, StateTranslated)
obj, (AllStates, StateAttrTranslated, StateTranslated)
) or super().is_safe_callable(obj)
def is_safe_attribute(self, obj, attr, value):

View File

@@ -492,3 +492,43 @@ def async_translate_state(
return translations[localize_key]
return state
@callback
def async_translate_state_attr(
hass: HomeAssistant,
attr_value: str,
domain: str,
platform: str | None,
translation_key: str | None,
device_class: str | None,
attribute_name: str,
) -> str:
"""Translate provided state attribute value using cached translations for currently selected language."""
language = hass.config.language
if platform is not None and translation_key is not None:
localize_key = (
f"component.{platform}.entity.{domain}"
f".{translation_key}.state_attributes.{attribute_name}"
f".state.{attr_value}"
)
translations = async_get_cached_translations(hass, language, "entity")
if localize_key in translations:
return translations[localize_key]
translations = async_get_cached_translations(hass, language, "entity_component")
if device_class is not None:
localize_key = (
f"component.{domain}.entity_component.{device_class}"
f".state_attributes.{attribute_name}.state.{attr_value}"
)
if localize_key in translations:
return translations[localize_key]
localize_key = (
f"component.{domain}.entity_component._"
f".state_attributes.{attribute_name}.state.{attr_value}"
)
if localize_key in translations:
return translations[localize_key]
return attr_value

View File

@@ -1043,6 +1043,151 @@ async def test_state_translated(
assert result == "unknown"
async def test_state_attr_translated(
hass: HomeAssistant, entity_registry: er.EntityRegistry
) -> None:
"""Test state_attr_translated method."""
await translation._async_get_translations_cache(hass).async_load("en", set())
hass.states.async_set(
"climate.living_room",
"heat",
attributes={"fan_mode": "auto", "hvac_action": "heating"},
)
hass.states.async_set(
"switch.test",
"on",
attributes={"some_attr": "some_value", "numeric_attr": 42, "bool_attr": True},
)
config_entry = MockConfigEntry(domain="climate")
config_entry.add_to_hass(hass)
entity_registry.async_get_or_create(
"climate",
"test_platform",
"5678",
config_entry=config_entry,
translation_key="my_climate",
)
hass.states.async_set(
"climate.test_platform_5678",
"heat",
attributes={"fan_mode": "auto"},
)
result = render(
hass,
'{{ state_attr_translated("switch.test", "some_attr") }}',
)
assert result == "some_value"
# Non-string attributes should be returned as-is without type conversion
result = render(
hass,
'{{ state_attr_translated("switch.test", "numeric_attr") }}',
)
assert result == 42
assert isinstance(result, int)
result = render(
hass,
'{{ state_attr_translated("switch.test", "bool_attr") }}',
)
assert result is True
result = render(
hass,
'{{ state_attr_translated("climate.non_existent", "fan_mode") }}',
)
assert result is None
with pytest.raises(TemplateError):
render(hass, '{{ state_attr_translated("-invalid", "fan_mode") }}')
result = render(
hass,
'{{ state_attr_translated("climate.living_room", "non_existent") }}',
)
assert result is None
@pytest.mark.parametrize(
(
"entity_id",
"attribute",
"translations",
"expected_result",
),
[
(
"climate.test_platform_5678",
"fan_mode",
{
"component.test_platform.entity.climate.my_climate.state_attributes.fan_mode.state.auto": "Platform Automatic",
},
"Platform Automatic",
),
(
"climate.living_room",
"fan_mode",
{
"component.climate.entity_component._.state_attributes.fan_mode.state.auto": "Automatic",
},
"Automatic",
),
(
"climate.living_room",
"hvac_action",
{
"component.climate.entity_component._.state_attributes.hvac_action.state.heating": "Heating",
},
"Heating",
),
],
)
async def test_state_attr_translated_translation_lookups(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
entity_id: str,
attribute: str,
translations: dict[str, str],
expected_result: str,
) -> None:
"""Test state_attr_translated translation lookups."""
await translation._async_get_translations_cache(hass).async_load("en", set())
hass.states.async_set(
"climate.living_room",
"heat",
attributes={"fan_mode": "auto", "hvac_action": "heating"},
)
config_entry = MockConfigEntry(domain="climate")
config_entry.add_to_hass(hass)
entity_registry.async_get_or_create(
"climate",
"test_platform",
"5678",
config_entry=config_entry,
translation_key="my_climate",
)
hass.states.async_set(
"climate.test_platform_5678",
"heat",
attributes={"fan_mode": "auto"},
)
with patch(
"homeassistant.helpers.translation.async_get_cached_translations",
return_value=translations,
):
result = render(
hass,
f'{{{{ state_attr_translated("{entity_id}", "{attribute}") }}}}',
)
assert result == expected_result
def test_has_value(hass: HomeAssistant) -> None:
"""Test has_value method."""
hass.states.async_set("test.value1", 1)

View File

@@ -683,6 +683,92 @@ async def test_translate_state(hass: HomeAssistant) -> None:
assert result == "on"
async def test_translate_state_attr(hass: HomeAssistant) -> None:
"""Test the state attribute translation helper."""
with patch(
"homeassistant.helpers.translation.async_get_cached_translations",
return_value={
"component.platform.entity.climate.translation_key.state_attributes.fan_mode.state.auto": "TRANSLATED"
},
) as mock:
result = translation.async_translate_state_attr(
hass,
"auto",
"climate",
"platform",
"translation_key",
None,
"fan_mode",
)
mock.assert_called_once_with(hass, hass.config.language, "entity")
assert result == "TRANSLATED"
with patch(
"homeassistant.helpers.translation.async_get_cached_translations",
return_value={
"component.climate.entity_component.device_class.state_attributes.fan_mode.state.auto": "TRANSLATED"
},
) as mock:
result = translation.async_translate_state_attr(
hass,
"auto",
"climate",
"platform",
None,
"device_class",
"fan_mode",
)
mock.assert_called_once_with(hass, hass.config.language, "entity_component")
assert result == "TRANSLATED"
with patch(
"homeassistant.helpers.translation.async_get_cached_translations",
return_value={
"component.climate.entity_component._.state_attributes.fan_mode.state.auto": "TRANSLATED"
},
) as mock:
result = translation.async_translate_state_attr(
hass, "auto", "climate", "platform", None, None, "fan_mode"
)
mock.assert_called_once_with(hass, hass.config.language, "entity_component")
assert result == "TRANSLATED"
with patch(
"homeassistant.helpers.translation.async_get_cached_translations",
return_value={},
) as mock:
result = translation.async_translate_state_attr(
hass, "auto", "climate", "platform", None, None, "fan_mode"
)
mock.assert_has_calls(
[
call(hass, hass.config.language, "entity_component"),
]
)
assert result == "auto"
with patch(
"homeassistant.helpers.translation.async_get_cached_translations",
return_value={},
) as mock:
result = translation.async_translate_state_attr(
hass,
"auto",
"climate",
"platform",
"translation_key",
"device_class",
"fan_mode",
)
mock.assert_has_calls(
[
call(hass, hass.config.language, "entity"),
call(hass, hass.config.language, "entity_component"),
]
)
assert result == "auto"
async def test_get_translations_still_has_title_without_translations_files(
hass: HomeAssistant, mock_config_flows
) -> None: