1
0
mirror of https://github.com/home-assistant/core.git synced 2026-04-02 00:20:30 +01:00

Update state template framework to support options other than state (#162737)

This commit is contained in:
Petro31
2026-03-23 09:26:21 -04:00
committed by GitHub
parent c53adcb73b
commit 152e17aee7
19 changed files with 122 additions and 63 deletions

View File

@@ -209,6 +209,7 @@ class AbstractTemplateAlarmControlPanel(
_entity_id_format = ENTITY_ID_FORMAT
_optimistic_entity = True
_state_option = CONF_STATE
# The super init is not called because TemplateEntity calls AbstractTemplateEntity.__init__.
def __init__(self, name: str) -> None: # pylint: disable=super-init-not-called
@@ -218,7 +219,6 @@ class AbstractTemplateAlarmControlPanel(
self._attr_code_format = self._config[CONF_CODE_FORMAT].value
self.setup_state_template(
CONF_STATE,
"_attr_alarm_state",
validator=tcv.strenum(self, CONF_STATE, AlarmControlPanelState),
)

View File

@@ -176,6 +176,7 @@ class AbstractTemplateBinarySensor(
"""Representation of a template binary sensor features."""
_entity_id_format = ENTITY_ID_FORMAT
_state_option = CONF_STATE
# The super init is not called because TemplateEntity and TriggerEntity will call AbstractTemplateEntity.__init__.
# This ensures that the __init__ on AbstractTemplateEntity is not called twice.
@@ -189,7 +190,6 @@ class AbstractTemplateBinarySensor(
self._delay_cancel: CALLBACK_TYPE | None = None
self.setup_state_template(
CONF_STATE,
"_attr_is_on",
on_update=self._update_state,
)

View File

@@ -215,6 +215,7 @@ class AbstractTemplateCover(AbstractTemplateEntity, CoverEntity):
_entity_id_format = ENTITY_ID_FORMAT
_optimistic_entity = True
_extra_optimistic_options = (CONF_POSITION,)
_state_option = CONF_STATE
# The super init is not called because TemplateEntity and TriggerEntity will call AbstractTemplateEntity.__init__.
# This ensures that the __init__ on AbstractTemplateEntity is not called twice.
@@ -222,7 +223,6 @@ class AbstractTemplateCover(AbstractTemplateEntity, CoverEntity):
"""Initialize the features."""
self.setup_state_template(
CONF_STATE,
"_attr_current_cover_position",
template_validators.strenum(
self, CONF_STATE, CoverState, CoverState.OPEN, CoverState.CLOSED

View File

@@ -3,10 +3,9 @@
from abc import abstractmethod
from collections.abc import Callable, Sequence
from dataclasses import dataclass
import logging
from typing import Any
from homeassistant.const import CONF_DEVICE_ID, CONF_OPTIMISTIC, CONF_STATE
from homeassistant.const import CONF_DEVICE_ID, CONF_OPTIMISTIC
from homeassistant.core import Context, HomeAssistant, callback
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.entity import Entity, async_generate_entity_id
@@ -16,8 +15,6 @@ from homeassistant.helpers.typing import ConfigType
from .const import CONF_DEFAULT_ENTITY_ID
_LOGGER = logging.getLogger(__name__)
@dataclass
class EntityTemplate:
@@ -36,7 +33,7 @@ class AbstractTemplateEntity(Entity):
_entity_id_format: str
_optimistic_entity: bool = False
_extra_optimistic_options: tuple[str, ...] | None = None
_template: Template | None = None
_state_option: str | None = None
def __init__(
self,
@@ -53,19 +50,18 @@ class AbstractTemplateEntity(Entity):
if self._optimistic_entity:
optimistic = config.get(CONF_OPTIMISTIC)
self._template = config.get(CONF_STATE)
if self._state_option is not None:
assumed_optimistic = config.get(self._state_option) is None
if self._extra_optimistic_options:
assumed_optimistic = assumed_optimistic and all(
config.get(option) is None
for option in self._extra_optimistic_options
)
assumed_optimistic = self._template is None
if self._extra_optimistic_options:
assumed_optimistic = assumed_optimistic and all(
config.get(option) is None
for option in self._extra_optimistic_options
self._attr_assumed_state = optimistic or (
optimistic is None and assumed_optimistic
)
self._attr_assumed_state = optimistic or (
optimistic is None and assumed_optimistic
)
if (default_entity_id := config.get(CONF_DEFAULT_ENTITY_ID)) is not None:
_, _, object_id = default_entity_id.partition(".")
self.entity_id = async_generate_entity_id(
@@ -89,12 +85,16 @@ class AbstractTemplateEntity(Entity):
@abstractmethod
def setup_state_template(
self,
option: str,
attribute: str,
validator: Callable[[Any], Any] | None = None,
on_update: Callable[[Any], None] | None = None,
) -> None:
"""Set up a template that manages the main state of the entity."""
"""Set up a template that manages the main state of the entity.
Requires _state_option to be set on the inheriting class. _state_option represents
the configuration option that derives the state. E.g. Template weather entities main state option
is 'condition', where switch is 'state'.
"""
@abstractmethod
def setup_template(

View File

@@ -207,13 +207,13 @@ class AbstractTemplateFan(AbstractTemplateEntity, FanEntity):
_entity_id_format = ENTITY_ID_FORMAT
_optimistic_entity = True
_state_option = CONF_STATE
# The super init is not called because TemplateEntity and TriggerEntity will call AbstractTemplateEntity.__init__.
# This ensures that the __init__ on AbstractTemplateEntity is not called twice.
def __init__(self, name: str, config: dict[str, Any]) -> None: # pylint: disable=super-init-not-called
"""Initialize the features."""
self.setup_state_template(
CONF_STATE,
"_attr_is_on",
template_validators.boolean(self, CONF_STATE),
)

View File

@@ -359,6 +359,7 @@ class AbstractTemplateLight(AbstractTemplateEntity, LightEntity):
_optimistic_entity = True
_attr_max_color_temp_kelvin = DEFAULT_MAX_KELVIN
_attr_min_color_temp_kelvin = DEFAULT_MIN_KELVIN
_state_option = CONF_STATE
# The super init is not called because TemplateEntity and TriggerEntity will call AbstractTemplateEntity.__init__.
# This ensures that the __init__ on AbstractTemplateEntity is not called twice.
@@ -369,7 +370,7 @@ class AbstractTemplateLight(AbstractTemplateEntity, LightEntity):
# Setup state and brightness
self.setup_state_template(
CONF_STATE, "_attr_is_on", template_validators.boolean(self, CONF_STATE)
"_attr_is_on", template_validators.boolean(self, CONF_STATE)
)
self.setup_template(
CONF_LEVEL,

View File

@@ -158,6 +158,7 @@ class AbstractTemplateLock(AbstractTemplateEntity, LockEntity):
_entity_id_format = ENTITY_ID_FORMAT
_optimistic_entity = True
_state_option = CONF_STATE
# The super init is not called because TemplateEntity and TriggerEntity will call AbstractTemplateEntity.__init__.
# This ensures that the __init__ on AbstractTemplateEntity is not called twice.
@@ -166,7 +167,6 @@ class AbstractTemplateLock(AbstractTemplateEntity, LockEntity):
self._code_format_template_error: TemplateError | None = None
self.setup_state_template(
CONF_STATE,
"_lock_state",
template_validators.strenum(
self, CONF_STATE, LockState, LockState.LOCKED, LockState.UNLOCKED
@@ -192,16 +192,18 @@ class AbstractTemplateLock(AbstractTemplateEntity, LockEntity):
self._attr_supported_features |= supported_feature
def _set_state(self, state: LockState | None) -> None:
if state is None:
self._attr_is_locked = None
return
self._attr_is_jammed = state == LockState.JAMMED
self._attr_is_opening = state == LockState.OPENING
self._attr_is_locking = state == LockState.LOCKING
self._attr_is_open = state == LockState.OPEN
self._attr_is_unlocking = state == LockState.UNLOCKING
self._attr_is_locked = state == LockState.LOCKED
# All other parameters need to be set False in order
# for the lock to be unknown.
if state is None:
self._attr_is_locked = state
else:
self._attr_is_locked = state == LockState.LOCKED
@callback
def _update_code_format(self, render: str | TemplateError | None):

View File

@@ -118,6 +118,7 @@ class AbstractTemplateNumber(AbstractTemplateEntity, NumberEntity):
_entity_id_format = ENTITY_ID_FORMAT
_optimistic_entity = True
_state_option = CONF_STATE
# The super init is not called because TemplateEntity and TriggerEntity will call AbstractTemplateEntity.__init__.
# This ensures that the __init__ on AbstractTemplateEntity is not called twice.
@@ -129,7 +130,6 @@ class AbstractTemplateNumber(AbstractTemplateEntity, NumberEntity):
self._attr_native_max_value = DEFAULT_MAX_VALUE
self.setup_state_template(
CONF_STATE,
"_attr_native_value",
template_validators.number(self, CONF_STATE),
)

View File

@@ -116,6 +116,7 @@ class AbstractTemplateSelect(AbstractTemplateEntity, SelectEntity):
_entity_id_format = ENTITY_ID_FORMAT
_optimistic_entity = True
_state_option = CONF_STATE
# The super init is not called because TemplateEntity and TriggerEntity will call AbstractTemplateEntity.__init__.
# This ensures that the __init__ on AbstractTemplateEntity is not called twice.
@@ -124,7 +125,6 @@ class AbstractTemplateSelect(AbstractTemplateEntity, SelectEntity):
self._attr_options = []
self.setup_state_template(
CONF_STATE,
"_attr_current_option",
cv.string,
)

View File

@@ -229,6 +229,7 @@ class AbstractTemplateSensor(AbstractTemplateEntity, RestoreSensor):
"""Representation of a template sensor features."""
_entity_id_format = ENTITY_ID_FORMAT
_state_option = CONF_STATE
# The super init is not called because TemplateEntity and TriggerEntity will call AbstractTemplateEntity.__init__.
# This ensures that the __init__ on AbstractTemplateEntity is not called twice.
@@ -240,7 +241,6 @@ class AbstractTemplateSensor(AbstractTemplateEntity, RestoreSensor):
self._attr_last_reset = None
self.setup_state_template(
CONF_STATE,
"_attr_native_value",
self._validate_state,
)

View File

@@ -155,6 +155,7 @@ class AbstractTemplateSwitch(AbstractTemplateEntity, SwitchEntity, RestoreEntity
_entity_id_format = ENTITY_ID_FORMAT
_optimistic_entity = True
_state_option = CONF_STATE
# The super init is not called because TemplateEntity and TriggerEntity will call AbstractTemplateEntity.__init__.
# This ensures that the __init__ on AbstractTemplateEntity is not called twice.
@@ -162,7 +163,6 @@ class AbstractTemplateSwitch(AbstractTemplateEntity, SwitchEntity, RestoreEntity
"""Initialize the features."""
self.setup_state_template(
CONF_STATE,
"_attr_is_on",
template_validators.boolean(self, CONF_STATE),
)

View File

@@ -292,12 +292,16 @@ class TemplateEntity(AbstractTemplateEntity):
def setup_state_template(
self,
option: str,
attribute: str,
validator: Callable[[Any], Any] | None = None,
on_update: Callable[[Any], None] | None = None,
) -> None:
"""Set up a template that manages the main state of the entity."""
"""Set up a template that manages the main state of the entity.
Requires _state_option to be set on the inheriting class. _state_option represents
the configuration option that derives the state. E.g. Template weather entities main state option
is 'condition', where switch is 'state'.
"""
@callback
def _update_state(result: Any) -> None:
@@ -314,13 +318,22 @@ class TemplateEntity(AbstractTemplateEntity):
self._attr_available = True
state = validator(result) if validator else result
if on_update:
on_update(state)
else:
setattr(self, attribute, state)
if self._state_option is None:
raise NotImplementedError(
f"{self.__class__.__name__} does not implement '_state_option' for 'setup_state_template'."
)
self.add_template(
option, attribute, on_update=_update_state, none_on_template_error=False
self._state_option,
attribute,
on_update=_update_state,
none_on_template_error=False,
)
def setup_template(

View File

@@ -5,7 +5,7 @@ from __future__ import annotations
from collections.abc import Callable
from typing import Any
from homeassistant.const import CONF_STATE, CONF_VARIABLES
from homeassistant.const import CONF_VARIABLES
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import TemplateError
from homeassistant.helpers.script_variables import ScriptVariables
@@ -60,17 +60,30 @@ class TriggerEntity( # pylint: disable=hass-enforce-class-module
def setup_state_template(
self,
option: str,
attribute: str,
validator: Callable[[Any], Any] | None = None,
on_update: Callable[[Any], None] | None = None,
) -> None:
"""Set up a template that manages the main state of the entity."""
"""Set up a template that manages the main state of the entity.
Requires _state_option to be set on the inheriting class. _state_option represents
the configuration option that derives the state. E.g. Template weather entities main state option
is 'condition', where switch is 'state'.
"""
if self._state_option is None:
raise NotImplementedError(
f"{self.__class__.__name__} does not implement '_state_option' for 'setup_state_template'."
)
if self.add_template(
option, attribute, validator, on_update, none_on_template_error=False
self._state_option,
attribute,
validator,
on_update,
none_on_template_error=False,
):
self._to_render_simple.append(option)
self._parse_result.add(option)
self._to_render_simple.append(self._state_option)
self._parse_result.add(self._state_option)
def setup_template(
self,
@@ -149,7 +162,7 @@ class TriggerEntity( # pylint: disable=hass-enforce-class-module
# Filter out state templates because they have unique behavior
# with none_on_template_error.
if (
key != CONF_STATE
key != self._state_option
and key in self._templates
and not self._templates[key].none_on_template_error
):
@@ -164,17 +177,21 @@ class TriggerEntity( # pylint: disable=hass-enforce-class-module
# If state fails to render, the entity should go unavailable. Render the
# state as a simple template because the result should always be a string or None.
if CONF_STATE in self._to_render_simple:
if (
state_option := self._state_option
) is not None and state_option in self._to_render_simple:
if (
result := self._render_single_template(CONF_STATE, variables)
result := self._render_single_template(state_option, variables)
) is _SENTINEL:
self._rendered = self._static_rendered
self._state_render_error = True
return
rendered[CONF_STATE] = result
rendered[state_option] = result
self._render_single_templates(rendered, variables, [CONF_STATE])
self._render_single_templates(
rendered, variables, [state_option] if state_option else []
)
self._render_attributes(rendered, variables)
self._rendered = rendered
@@ -182,6 +199,10 @@ class TriggerEntity( # pylint: disable=hass-enforce-class-module
"""Get a rendered result and return the value."""
# Handle any templates.
write_state = False
if self._state_render_error:
# The state errored and the entity is unavailable, do not process any values.
return True
for option, entity_template in self._templates.items():
# Capture templates that did not render a result due to an exception and
# ensure the state object updates. _SENTINEL is used to differentiate
@@ -225,18 +246,7 @@ class TriggerEntity( # pylint: disable=hass-enforce-class-module
if self._render_availability_template(variables):
self._render_templates(variables)
write_state = False
# While transitioning platforms to the new framework, this
# if-statement is necessary for backward compatibility with existing
# trigger based platforms.
if self._templates:
# Handle any results that were rendered.
write_state = self._handle_rendered_results()
# Check availability after rendering the results because the state
# template could render the entity unavailable
if not self.available:
write_state = True
write_state = self._handle_rendered_results()
if len(self._rendered) > 0:
# In some cases, the entity may be state optimistic or

View File

@@ -219,6 +219,7 @@ class AbstractTemplateVacuum(AbstractTemplateEntity, StateVacuumEntity):
_entity_id_format = ENTITY_ID_FORMAT
_optimistic_entity = True
_state_option = CONF_STATE
# The super init is not called because TemplateEntity and TriggerEntity will call AbstractTemplateEntity.__init__.
# This ensures that the __init__ on AbstractTemplateEntity is not called twice.
@@ -228,7 +229,6 @@ class AbstractTemplateVacuum(AbstractTemplateEntity, StateVacuumEntity):
# List of valid fan speeds
self._attr_fan_speed_list = config[CONF_FAN_SPEED_LIST]
self.setup_state_template(
CONF_STATE,
"_attr_activity",
template_validators.strenum(self, CONF_STATE, VacuumActivity),
)

View File

@@ -389,6 +389,7 @@ class AbstractTemplateWeather(AbstractTemplateEntity, WeatherEntity):
"""Representation of a template weathers features."""
_entity_id_format = ENTITY_ID_FORMAT
_state_option = CONF_CONDITION
_optimistic_entity = True
# The super init is not called because TemplateEntity and TriggerEntity will call AbstractTemplateEntity.__init__.
@@ -399,8 +400,7 @@ class AbstractTemplateWeather(AbstractTemplateEntity, WeatherEntity):
"""Initialize the features."""
# Required options
self.setup_template(
CONF_CONDITION,
self.setup_state_template(
"_attr_condition",
template_validators.item_in_list(self, CONF_CONDITION, CONDITION_CLASSES),
)

View File

@@ -283,7 +283,6 @@
# name: test_setup_config_entry
StateSnapshot({
'attributes': ReadOnlyDict({
'assumed_state': True,
'attribution': 'Powered by Home Assistant',
'friendly_name': 'My template',
'humidity': 50,

View File

@@ -325,6 +325,12 @@ async def test_template_state(hass: HomeAssistant) -> None:
state = hass.states.get(TEST_ENTITY_ID)
assert state.state == LockState.OPEN
hass.states.async_set(TEST_STATE_ENTITY_ID, "None")
await hass.async_block_till_done()
state = hass.states.get(TEST_ENTITY_ID)
assert state.state == STATE_UNKNOWN
@pytest.mark.parametrize(
("count", "state_template", "extra_config"),

View File

@@ -24,11 +24,12 @@ class TestEntity(trigger_entity.TriggerEntity):
__test__ = False
_entity_id_format = "test.{}"
extra_template_keys = (CONF_STATE,)
_state_option = CONF_STATE
@property
def state(self) -> bool | None:
"""Return extra attributes."""
return self._rendered.get(CONF_STATE)
return self._rendered.get(self._state_option)
async def test_reference_blueprints_is_none(hass: HomeAssistant) -> None:

View File

@@ -106,6 +106,28 @@ async def setup_weather(
await setup_entity(hass, TEST_WEATHER, style, 1, config)
@pytest.mark.parametrize(
"style", [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER]
)
@pytest.mark.parametrize(
"config",
[
{
"condition": "{{ x - 2 }}",
"temperature": "{{ 20 }}",
"humidity": "{{ 25 }}",
},
],
)
@pytest.mark.usefixtures("setup_weather")
async def test_template_state_exception(hass: HomeAssistant) -> None:
"""Test condition produces exception."""
await async_trigger(hass, "sensor.condition", "anything")
state = hass.states.get(TEST_WEATHER.entity_id)
assert state is not None
assert state.state == STATE_UNAVAILABLE
@pytest.mark.parametrize(
("style", "config"),
[
@@ -240,6 +262,11 @@ async def test_template_state_text(
entity_id == "sensor.uv_index" and style == ConfigurationStyle.LEGACY
)
await async_trigger(hass, "sensor.condition", "None")
state = hass.states.get(TEST_WEATHER.entity_id)
assert state is not None
assert state.state == STATE_UNKNOWN
@pytest.mark.parametrize(
("style", "config"),