Update template fan platform to the new entity framework (#162328)

This commit is contained in:
Petro31
2026-02-09 08:13:23 -05:00
committed by GitHub
parent 8d37917d8b
commit c945f32989
2 changed files with 67 additions and 225 deletions

View File

@@ -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)

View File

@@ -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],
),
],
)