diff --git a/homeassistant/components/template/cover.py b/homeassistant/components/template/cover.py index 4eb2e9ef4ec..4c3c963ea65 100644 --- a/homeassistant/components/template/cover.py +++ b/homeassistant/components/template/cover.py @@ -2,7 +2,6 @@ from __future__ import annotations -from collections.abc import Generator, Sequence import logging from typing import TYPE_CHECKING, Any @@ -17,6 +16,7 @@ from homeassistant.components.cover import ( PLATFORM_SCHEMA as COVER_PLATFORM_SCHEMA, CoverEntity, CoverEntityFeature, + CoverState, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -30,15 +30,14 @@ from homeassistant.const import ( CONF_VALUE_TEMPLATE, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import TemplateError -from homeassistant.helpers import config_validation as cv, template +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, ) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import TriggerUpdateCoordinator +from . import TriggerUpdateCoordinator, validators as template_validators from .const import DOMAIN from .entity import AbstractTemplateEntity from .helpers import ( @@ -222,25 +221,38 @@ class AbstractTemplateCover(AbstractTemplateEntity, CoverEntity): # 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, config: dict[str, Any]) -> None: # pylint: disable=super-init-not-called + def __init__(self, name: str, config: dict[str, Any]) -> None: # pylint: disable=super-init-not-called """Initialize the features.""" - self._position_template = config.get(CONF_POSITION) - self._tilt_template = config.get(CONF_TILT) + self.setup_state_template( + CONF_STATE, + "_attr_current_cover_position", + template_validators.strenum( + self, CONF_STATE, CoverState, CoverState.OPEN, CoverState.CLOSED + ), + self._update_cover_state, + ) + self.setup_template( + CONF_POSITION, + "_attr_current_cover_position", + template_validators.number(self, CONF_POSITION, 0, 100), + ) + self.setup_template( + CONF_TILT, + "_attr_current_cover_tilt_position", + template_validators.number(self, CONF_TILT, 0, 100), + ) self._attr_device_class = config.get(CONF_DEVICE_CLASS) - tilt_optimistic = config.get(CONF_TILT_OPTIMISTIC) - self._tilt_optimistic = tilt_optimistic or not self._tilt_template + self._tilt_optimistic = ( + config.get(CONF_TILT_OPTIMISTIC) or CONF_TILT not in self._templates + ) # The config requires (open and close scripts) or a set position script, # therefore the base supported features will always include them. self._attr_supported_features: CoverEntityFeature = ( CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE ) - - def _iterate_scripts( - self, config: dict[str, Any] - ) -> Generator[tuple[str, Sequence[dict[str, Any]], CoverEntityFeature | int]]: for action_id, supported_feature in ( (OPEN_ACTION, 0), (CLOSE_ACTION, 0), @@ -249,7 +261,8 @@ class AbstractTemplateCover(AbstractTemplateEntity, CoverEntity): (TILT_ACTION, TILT_FEATURES), ): if (action_config := config.get(action_id)) is not None: - yield (action_id, action_config, supported_feature) + self.add_script(action_id, action_config, name, DOMAIN) + self._attr_supported_features |= supported_feature @property def is_closed(self) -> bool | None: @@ -259,70 +272,19 @@ class AbstractTemplateCover(AbstractTemplateEntity, CoverEntity): return self._attr_current_cover_position == 0 - @callback - def _update_position(self, result): - if result is None: - self._attr_current_cover_position = None - return - - try: - state = float(result) - except ValueError as err: - _LOGGER.error(err) - self._attr_current_cover_position = None - return - - if state < 0 or state > 100: - self._attr_current_cover_position = None - _LOGGER.error( - "Cover position value must be between 0 and 100. Value was: %.2f", - state, - ) - else: - self._attr_current_cover_position = state - - @callback - def _update_tilt(self, result): - if result is None: - self._attr_current_cover_tilt_position = None - return - - try: - state = float(result) - except ValueError as err: - _LOGGER.error(err) - self._attr_current_cover_tilt_position = None - return - - if state < 0 or state > 100: - self._attr_current_cover_tilt_position = None - _LOGGER.error( - "Tilt value must be between 0 and 100. Value was: %.2f", - state, - ) - else: - self._attr_current_cover_tilt_position = state - - def _update_opening_and_closing(self, result: Any) -> None: - state = str(result).lower() - - if state in _VALID_STATES: - if not self._position_template: - if state in ("true", OPEN_STATE): + def _update_cover_state(self, state: CoverState | None) -> None: + """Update the state of the cover.""" + if state: + if CONF_POSITION not in self._templates: + if state == CoverState.OPEN: self._attr_current_cover_position = 100 else: self._attr_current_cover_position = 0 - self._attr_is_opening = state == OPENING_STATE - self._attr_is_closing = state == CLOSING_STATE + self._attr_is_opening = state == CoverState.OPENING + self._attr_is_closing = state == CoverState.CLOSING else: - _LOGGER.error( - "Received invalid cover is_on state: %s for entity %s. Expected: %s", - state, - self.entity_id, - ", ".join(_VALID_STATES), - ) - if not self._position_template: + if CONF_POSITION not in self._templates: self._attr_current_cover_position = None self._attr_is_opening = False @@ -419,50 +381,11 @@ class StateCoverEntity(TemplateEntity, AbstractTemplateCover): ) -> None: """Initialize the Template cover.""" TemplateEntity.__init__(self, hass, config, unique_id) - AbstractTemplateCover.__init__(self, config) name = self._attr_name if TYPE_CHECKING: assert name is not None - for action_id, action_config, supported_feature in self._iterate_scripts( - config - ): - self.add_script(action_id, action_config, name, DOMAIN) - self._attr_supported_features |= supported_feature - - @callback - def _async_setup_templates(self) -> None: - """Set up templates.""" - if self._template: - self.add_template_attribute( - "_attr_current_cover_position", self._template, None, self._update_state - ) - if self._position_template: - self.add_template_attribute( - "_attr_current_cover_position", - self._position_template, - None, - self._update_position, - none_on_template_error=True, - ) - if self._tilt_template: - self.add_template_attribute( - "_attr_current_cover_tilt_position", - self._tilt_template, - None, - self._update_tilt, - none_on_template_error=True, - ) - super()._async_setup_templates() - - @callback - def _update_state(self, result): - super()._update_state(result) - if isinstance(result, TemplateError): - self._attr_current_cover_position = None - return - - self._update_opening_and_closing(result) + AbstractTemplateCover.__init__(self, name, config) class TriggerCoverEntity(TriggerEntity, AbstractTemplateCover): @@ -478,45 +401,6 @@ class TriggerCoverEntity(TriggerEntity, AbstractTemplateCover): ) -> None: """Initialize the entity.""" TriggerEntity.__init__(self, hass, coordinator, config) - AbstractTemplateCover.__init__(self, config) - # Render the _attr_name before initializing TriggerCoverEntity self._attr_name = name = self._rendered.get(CONF_NAME, DEFAULT_NAME) - - for action_id, action_config, supported_feature in self._iterate_scripts( - config - ): - self.add_script(action_id, action_config, name, DOMAIN) - self._attr_supported_features |= supported_feature - - for key in (CONF_STATE, CONF_POSITION, CONF_TILT): - if isinstance(config.get(key), template.Template): - self._to_render_simple.append(key) - self._parse_result.add(key) - - @callback - def _handle_coordinator_update(self) -> None: - """Handle update of the data.""" - self._process_data() - - if not self.available: - return - - write_ha_state = False - for key, updater in ( - (CONF_STATE, self._update_opening_and_closing), - (CONF_POSITION, self._update_position), - (CONF_TILT, self._update_tilt), - ): - if (rendered := self._rendered.get(key)) is not None: - updater(rendered) - write_ha_state = True - - if not self._attr_assumed_state: - write_ha_state = True - elif self._attr_assumed_state and len(self._rendered) > 0: - # In case any non optimistic template - write_ha_state = True - - if write_ha_state: - self.async_write_ha_state() + AbstractTemplateCover.__init__(self, name, config) diff --git a/tests/components/template/test_cover.py b/tests/components/template/test_cover.py index 2a83967b048..adf093200b3 100644 --- a/tests/components/template/test_cover.py +++ b/tests/components/template/test_cover.py @@ -358,9 +358,9 @@ async def setup_empty_action( (CoverState.CLOSED, CoverState.CLOSED, ""), (CoverState.OPENING, CoverState.OPENING, ""), (CoverState.CLOSING, CoverState.CLOSING, ""), - ("dog", STATE_UNKNOWN, "Received invalid cover is_on state: dog"), - ("cat", STATE_UNKNOWN, "Received invalid cover is_on state: cat"), - ("bear", STATE_UNKNOWN, "Received invalid cover is_on state: bear"), + ("dog", STATE_UNKNOWN, "Received invalid cover state: dog"), + ("cat", STATE_UNKNOWN, "Received invalid cover state: cat"), + ("bear", STATE_UNKNOWN, "Received invalid cover state: bear"), ], ) @pytest.mark.usefixtures("setup_state_cover") @@ -459,7 +459,7 @@ async def test_template_state_states( TEST_STATE_ENTITY_ID, "dog", CoverState.OPEN, - "Received invalid cover is_on state: dog", + "Received invalid cover state: dog", None, ), ] diff --git a/tests/components/template/test_trigger_entity.py b/tests/components/template/test_trigger_entity.py index 9e745ccc33a..d977897efbf 100644 --- a/tests/components/template/test_trigger_entity.py +++ b/tests/components/template/test_trigger_entity.py @@ -192,3 +192,40 @@ async def test_bad_default_entity_id(hass: HomeAssistant) -> None: coordinator = TriggerUpdateCoordinator(hass, {}) entity = TestEntity(hass, coordinator, {"default_entity_id": "bad.test"}) assert entity.entity_id == "test.test" + + +async def test_multiple_template_validators(hass: HomeAssistant) -> None: + """Tests multiple templates execute validators.""" + await async_trigger(hass, "sensor.state", "opening") + await async_trigger(hass, "sensor.position", "50") + await async_trigger(hass, "sensor.tilt", "49") + with assert_setup_component(1, DOMAIN): + assert await async_setup_component( + hass, + DOMAIN, + { + "template": { + "triggers": { + "trigger": "state", + "entity_id": ["sensor.trigger"], + }, + "cover": { + "name": "test", + "state": "{{ states('sensor.state') }}", + "position": "{{ states('sensor.position') }}", + "tilt": "{{ states('sensor.tilt') }}", + "set_cover_position": [], + "set_cover_tilt_position": [], + "open_cover": [], + "close_cover": [], + }, + }, + }, + ) + await async_trigger(hass, "sensor.trigger", "anything") + + state = hass.states.get("cover.test") + assert state + assert state.state == "opening" + assert state.attributes["current_position"] == 50 + assert state.attributes["current_tilt_position"] == 49