diff --git a/homeassistant/components/template/alarm_control_panel.py b/homeassistant/components/template/alarm_control_panel.py index e6101df2919..90c0bb0a56f 100644 --- a/homeassistant/components/template/alarm_control_panel.py +++ b/homeassistant/components/template/alarm_control_panel.py @@ -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), ) diff --git a/homeassistant/components/template/binary_sensor.py b/homeassistant/components/template/binary_sensor.py index 57825648cf7..8bccb47687d 100644 --- a/homeassistant/components/template/binary_sensor.py +++ b/homeassistant/components/template/binary_sensor.py @@ -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, ) diff --git a/homeassistant/components/template/cover.py b/homeassistant/components/template/cover.py index 397c777e36f..e7cf443ee70 100644 --- a/homeassistant/components/template/cover.py +++ b/homeassistant/components/template/cover.py @@ -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 diff --git a/homeassistant/components/template/entity.py b/homeassistant/components/template/entity.py index 7e110c6380a..f7b5c3ff989 100644 --- a/homeassistant/components/template/entity.py +++ b/homeassistant/components/template/entity.py @@ -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( diff --git a/homeassistant/components/template/fan.py b/homeassistant/components/template/fan.py index bb12499d13a..4e29a77f058 100644 --- a/homeassistant/components/template/fan.py +++ b/homeassistant/components/template/fan.py @@ -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), ) diff --git a/homeassistant/components/template/light.py b/homeassistant/components/template/light.py index 00d9109e0e2..0ff0df03e71 100644 --- a/homeassistant/components/template/light.py +++ b/homeassistant/components/template/light.py @@ -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, diff --git a/homeassistant/components/template/lock.py b/homeassistant/components/template/lock.py index 8f122ff8ce1..cc527a4a050 100644 --- a/homeassistant/components/template/lock.py +++ b/homeassistant/components/template/lock.py @@ -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): diff --git a/homeassistant/components/template/number.py b/homeassistant/components/template/number.py index 9c49da87021..9dd62100917 100644 --- a/homeassistant/components/template/number.py +++ b/homeassistant/components/template/number.py @@ -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), ) diff --git a/homeassistant/components/template/select.py b/homeassistant/components/template/select.py index 019c5f1a6ca..1eb7c77b40a 100644 --- a/homeassistant/components/template/select.py +++ b/homeassistant/components/template/select.py @@ -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, ) diff --git a/homeassistant/components/template/sensor.py b/homeassistant/components/template/sensor.py index a3184c4ba98..febde76c6b0 100644 --- a/homeassistant/components/template/sensor.py +++ b/homeassistant/components/template/sensor.py @@ -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, ) diff --git a/homeassistant/components/template/switch.py b/homeassistant/components/template/switch.py index cf2ed080421..4689d96989d 100644 --- a/homeassistant/components/template/switch.py +++ b/homeassistant/components/template/switch.py @@ -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), ) diff --git a/homeassistant/components/template/template_entity.py b/homeassistant/components/template/template_entity.py index a45c5e5e66a..c98c740a9f3 100644 --- a/homeassistant/components/template/template_entity.py +++ b/homeassistant/components/template/template_entity.py @@ -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( diff --git a/homeassistant/components/template/trigger_entity.py b/homeassistant/components/template/trigger_entity.py index 734baf7904a..134c42bded1 100644 --- a/homeassistant/components/template/trigger_entity.py +++ b/homeassistant/components/template/trigger_entity.py @@ -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 diff --git a/homeassistant/components/template/vacuum.py b/homeassistant/components/template/vacuum.py index c04c294aca3..f06ae13141b 100644 --- a/homeassistant/components/template/vacuum.py +++ b/homeassistant/components/template/vacuum.py @@ -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), ) diff --git a/homeassistant/components/template/weather.py b/homeassistant/components/template/weather.py index fb158f71573..45b8a578b89 100644 --- a/homeassistant/components/template/weather.py +++ b/homeassistant/components/template/weather.py @@ -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), ) diff --git a/tests/components/template/snapshots/test_weather.ambr b/tests/components/template/snapshots/test_weather.ambr index 1dd1e1bc7af..f50234982ae 100644 --- a/tests/components/template/snapshots/test_weather.ambr +++ b/tests/components/template/snapshots/test_weather.ambr @@ -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, diff --git a/tests/components/template/test_lock.py b/tests/components/template/test_lock.py index 8b61b9489af..ae3297b050b 100644 --- a/tests/components/template/test_lock.py +++ b/tests/components/template/test_lock.py @@ -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"), diff --git a/tests/components/template/test_trigger_entity.py b/tests/components/template/test_trigger_entity.py index d977897efbf..b67b4803dfd 100644 --- a/tests/components/template/test_trigger_entity.py +++ b/tests/components/template/test_trigger_entity.py @@ -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: diff --git a/tests/components/template/test_weather.py b/tests/components/template/test_weather.py index 223f6d43d63..1d96acdf0df 100644 --- a/tests/components/template/test_weather.py +++ b/tests/components/template/test_weather.py @@ -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"),