diff --git a/homeassistant/components/template/fan.py b/homeassistant/components/template/fan.py index a1d5df0e944..feb31129b3c 100644 --- a/homeassistant/components/template/fan.py +++ b/homeassistant/components/template/fan.py @@ -2,7 +2,6 @@ from __future__ import annotations -from collections.abc import Generator, Sequence import logging from typing import TYPE_CHECKING, Any @@ -28,19 +27,16 @@ from homeassistant.const import ( CONF_STATE, CONF_UNIQUE_ID, CONF_VALUE_TEMPLATE, - STATE_ON, - STATE_UNAVAILABLE, - STATE_UNKNOWN, ) 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 validators as template_validators from .const import DOMAIN from .coordinator import TriggerUpdateCoordinator from .entity import AbstractTemplateEntity @@ -203,30 +199,53 @@ class AbstractTemplateFan(AbstractTemplateEntity, FanEntity): # 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._percentage_template = config.get(CONF_PERCENTAGE) - self._preset_mode_template = config.get(CONF_PRESET_MODE) - self._oscillating_template = config.get(CONF_OSCILLATING) - self._direction_template = config.get(CONF_DIRECTION) + self.setup_state_template( + CONF_STATE, + "_attr_is_on", + template_validators.boolean(self, CONF_STATE), + ) - # Required for legacy functionality. - self._attr_is_on = False + # Ensure legacy template entity functionality by setting percentage to None instead + # of the FanEntity default of 0. self._attr_percentage = None + self.setup_template( + CONF_PERCENTAGE, + "_attr_percentage", + template_validators.number(self, CONF_PERCENTAGE, 0, 100), + ) + + # List of valid preset modes + self._attr_preset_modes: list[str] | None = config.get(CONF_PRESET_MODES) + self.setup_template( + CONF_PRESET_MODE, + "_attr_preset_mode", + template_validators.item_in_list( + self, CONF_PRESET_MODE, self._attr_preset_modes + ), + ) + + # Oscillating boolean + self.setup_template( + CONF_OSCILLATING, + "_attr_oscillating", + template_validators.boolean(self, CONF_OSCILLATING), + ) + + # Forward/Reverse Directions + self.setup_template( + CONF_DIRECTION, + "_attr_current_direction", + template_validators.item_in_list(self, CONF_DIRECTION, _VALID_DIRECTIONS), + ) # Number of valid speeds self._attr_speed_count = config.get(CONF_SPEED_COUNT) or 100 - # List of valid preset modes - self._attr_preset_modes: list[str] | None = config.get(CONF_PRESET_MODES) - self._attr_supported_features |= ( FanEntityFeature.TURN_OFF | FanEntityFeature.TURN_ON ) - - def _iterate_scripts( - self, config: dict[str, Any] - ) -> Generator[tuple[str, Sequence[dict[str, Any]], FanEntityFeature | int]]: for action_id, supported_feature in ( (CONF_ON_ACTION, 0), (CONF_OFF_ACTION, 0), @@ -236,99 +255,14 @@ class AbstractTemplateFan(AbstractTemplateEntity, FanEntity): (CONF_SET_DIRECTION_ACTION, FanEntityFeature.DIRECTION), ): 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_on(self) -> bool | None: """Return true if device is on.""" return self._attr_is_on - def _handle_state(self, result) -> None: - if isinstance(result, bool): - self._attr_is_on = result - return - - if isinstance(result, str): - self._attr_is_on = result.lower() in ("true", STATE_ON) - return - - self._attr_is_on = False - - @callback - def _update_percentage(self, percentage): - # Validate percentage - try: - percentage = int(float(percentage)) - except ValueError, TypeError: - _LOGGER.error( - "Received invalid percentage: %s for entity %s", - percentage, - self.entity_id, - ) - self._attr_percentage = 0 - return - - if 0 <= percentage <= 100: - self._attr_percentage = percentage - else: - _LOGGER.error( - "Received invalid percentage: %s for entity %s", - percentage, - self.entity_id, - ) - self._attr_percentage = 0 - - @callback - def _update_preset_mode(self, preset_mode): - # Validate preset mode - preset_mode = str(preset_mode) - - if self.preset_modes and preset_mode in self.preset_modes: - self._attr_preset_mode = preset_mode - elif preset_mode in (STATE_UNAVAILABLE, STATE_UNKNOWN): - self._attr_preset_mode = None - else: - _LOGGER.error( - "Received invalid preset_mode: %s for entity %s. Expected: %s", - preset_mode, - self.entity_id, - self.preset_mode, - ) - self._attr_preset_mode = None - - @callback - def _update_oscillating(self, oscillating): - # Validate osc - if oscillating == "True" or oscillating is True: - self._attr_oscillating = True - elif oscillating == "False" or oscillating is False: - self._attr_oscillating = False - elif oscillating in (STATE_UNAVAILABLE, STATE_UNKNOWN): - self._attr_oscillating = None - else: - _LOGGER.error( - "Received invalid oscillating: %s for entity %s. Expected: True/False", - oscillating, - self.entity_id, - ) - self._attr_oscillating = None - - @callback - def _update_direction(self, direction): - # Validate direction - if direction in _VALID_DIRECTIONS: - self._attr_current_direction = direction - elif direction in (STATE_UNAVAILABLE, STATE_UNKNOWN): - self._attr_current_direction = None - else: - _LOGGER.error( - "Received invalid direction: %s for entity %s. Expected: %s", - direction, - self.entity_id, - ", ".join(_VALID_DIRECTIONS), - ) - self._attr_current_direction = None - async def async_turn_on( self, percentage: int | None = None, @@ -378,7 +312,7 @@ class AbstractTemplateFan(AbstractTemplateEntity, FanEntity): if self._attr_assumed_state: self._attr_is_on = percentage != 0 - if self._attr_assumed_state or self._percentage_template is None: + if self._attr_assumed_state or CONF_PERCENTAGE not in self._templates: self.async_write_ha_state() async def async_set_preset_mode(self, preset_mode: str) -> None: @@ -395,7 +329,7 @@ class AbstractTemplateFan(AbstractTemplateEntity, FanEntity): if self._attr_assumed_state: self._attr_is_on = True - if self._attr_assumed_state or self._preset_mode_template is None: + if self._attr_assumed_state or CONF_PRESET_MODE not in self._templates: self.async_write_ha_state() async def async_oscillate(self, oscillating: bool) -> None: @@ -410,7 +344,7 @@ class AbstractTemplateFan(AbstractTemplateEntity, FanEntity): context=self._context, ) - if self._oscillating_template is None: + if CONF_OSCILLATING not in self._templates: self.async_write_ha_state() async def async_set_direction(self, direction: str) -> None: @@ -425,7 +359,7 @@ class AbstractTemplateFan(AbstractTemplateEntity, FanEntity): run_variables={ATTR_DIRECTION: direction}, context=self._context, ) - if self._direction_template is None: + if CONF_DIRECTION not in self._templates: self.async_write_ha_state() else: _LOGGER.error( @@ -449,67 +383,10 @@ class StateFanEntity(TemplateEntity, AbstractTemplateFan): ) -> None: """Initialize the fan.""" TemplateEntity.__init__(self, hass, config, unique_id) - AbstractTemplateFan.__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 _update_state(self, result): - super()._update_state(result) - if isinstance(result, TemplateError): - self._attr_is_on = None - return - - self._handle_state(result) - - @callback - def _async_setup_templates(self) -> None: - """Set up templates.""" - if self._template: - self.add_template_attribute( - "_attr_is_on", self._template, None, self._update_state - ) - - if self._preset_mode_template is not None: - self.add_template_attribute( - "_attr_preset_mode", - self._preset_mode_template, - None, - self._update_preset_mode, - none_on_template_error=True, - ) - if self._percentage_template is not None: - self.add_template_attribute( - "_attr_percentage", - self._percentage_template, - None, - self._update_percentage, - none_on_template_error=True, - ) - if self._oscillating_template is not None: - self.add_template_attribute( - "_attr_oscillating", - self._oscillating_template, - None, - self._update_oscillating, - none_on_template_error=True, - ) - if self._direction_template is not None: - self.add_template_attribute( - "_attr_current_direction", - self._direction_template, - None, - self._update_direction, - none_on_template_error=True, - ) - super()._async_setup_templates() + AbstractTemplateFan.__init__(self, name, config) class TriggerFanEntity(TriggerEntity, AbstractTemplateFan): @@ -525,50 +402,5 @@ class TriggerFanEntity(TriggerEntity, AbstractTemplateFan): ) -> None: """Initialize the entity.""" TriggerEntity.__init__(self, hass, coordinator, config) - AbstractTemplateFan.__init__(self, config) - 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_PRESET_MODE, - CONF_PERCENTAGE, - CONF_OSCILLATING, - CONF_DIRECTION, - ): - 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._handle_state), - (CONF_PRESET_MODE, self._update_preset_mode), - (CONF_PERCENTAGE, self._update_percentage), - (CONF_OSCILLATING, self._update_oscillating), - (CONF_DIRECTION, self._update_direction), - ): - if (rendered := self._rendered.get(key)) is not None: - updater(rendered) - write_ha_state = True - - if len(self._rendered) > 0: - # In case any non optimistic template - write_ha_state = True - - if write_ha_state: - self.async_write_ha_state() + AbstractTemplateFan.__init__(self, name, config) diff --git a/tests/components/template/test_fan.py b/tests/components/template/test_fan.py index 81486d75137..b810fac4715 100644 --- a/tests/components/template/test_fan.py +++ b/tests/components/template/test_fan.py @@ -17,7 +17,7 @@ from homeassistant.components.fan import ( FanEntityFeature, NotValidPresetModeError, ) -from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE +from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component @@ -469,7 +469,17 @@ async def test_state_template(hass: HomeAssistant) -> None: ("{{ True }}", STATE_ON), ("{{ False }}", STATE_OFF), ("{{ x - 1 }}", STATE_UNAVAILABLE), - ("{{ 7.45 }}", STATE_OFF), + ("{{ 1 }}", STATE_ON), + ("{{ 'true' }}", STATE_ON), + ("{{ 'yes' }}", STATE_ON), + ("{{ 'on' }}", STATE_ON), + ("{{ 'enable' }}", STATE_ON), + ("{{ 0 }}", STATE_OFF), + ("{{ 'false' }}", STATE_OFF), + ("{{ 'no' }}", STATE_OFF), + ("{{ 'off' }}", STATE_OFF), + ("{{ 'disable' }}", STATE_OFF), + ("{{ None }}", STATE_UNKNOWN), ], ) @pytest.mark.parametrize( @@ -564,8 +574,8 @@ async def test_icon_template(hass: HomeAssistant) -> None: [ ("0", 0), ("33", 33), - ("invalid", 0), - ("5000", 0), + ("invalid", None), + ("5000", None), ("100", 100), ], ) @@ -751,7 +761,7 @@ async def test_availability_template_with_entities(hass: HomeAssistant) -> None: "value_template": "{{ 'unavailable' }}", **OPTIMISTIC_ON_OFF_ACTIONS, }, - [STATE_OFF, None, None, None], + [STATE_UNKNOWN, None, None, None], ), ( ConfigurationStyle.MODERN, @@ -759,7 +769,7 @@ async def test_availability_template_with_entities(hass: HomeAssistant) -> None: "state": "{{ 'unavailable' }}", **OPTIMISTIC_ON_OFF_ACTIONS, }, - [STATE_OFF, None, None, None], + [STATE_UNKNOWN, None, None, None], ), ( ConfigurationStyle.TRIGGER, @@ -767,7 +777,7 @@ async def test_availability_template_with_entities(hass: HomeAssistant) -> None: "state": "{{ 'unavailable' }}", **OPTIMISTIC_ON_OFF_ACTIONS, }, - [STATE_OFF, None, None, None], + [STATE_UNKNOWN, None, None, None], ), ( ConfigurationStyle.LEGACY, @@ -858,7 +868,7 @@ async def test_availability_template_with_entities(hass: HomeAssistant) -> None: "direction_template": "{{ 'right' }}", **DIRECTION_ACTION, }, - [STATE_OFF, 0, None, None], + [STATE_UNKNOWN, 0, None, None], ), ( ConfigurationStyle.MODERN, @@ -871,7 +881,7 @@ async def test_availability_template_with_entities(hass: HomeAssistant) -> None: "direction": "{{ 'right' }}", **DIRECTION_ACTION, }, - [STATE_OFF, 0, None, None], + [STATE_UNKNOWN, 0, None, None], ), ( ConfigurationStyle.TRIGGER, @@ -884,7 +894,7 @@ async def test_availability_template_with_entities(hass: HomeAssistant) -> None: "direction": "{{ 'right' }}", **DIRECTION_ACTION, }, - [STATE_OFF, 0, None, None], + [STATE_UNKNOWN, 0, None, None], ), ], )