Correct template fan optimistic mode and supported features (#142414)

This commit is contained in:
Petro31 2025-04-14 09:40:29 -04:00 committed by GitHub
parent aeca2842fe
commit 9b274a0bc4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 398 additions and 145 deletions

View File

@ -149,17 +149,21 @@ class TemplateFan(TemplateEntity, FanEntity):
self._oscillating_template = config.get(CONF_OSCILLATING_TEMPLATE)
self._direction_template = config.get(CONF_DIRECTION_TEMPLATE)
for action_id in (
CONF_ON_ACTION,
CONF_OFF_ACTION,
CONF_SET_PERCENTAGE_ACTION,
CONF_SET_PRESET_MODE_ACTION,
CONF_SET_OSCILLATING_ACTION,
CONF_SET_DIRECTION_ACTION,
self._attr_supported_features |= (
FanEntityFeature.TURN_OFF | FanEntityFeature.TURN_ON
)
for action_id, supported_feature in (
(CONF_ON_ACTION, 0),
(CONF_OFF_ACTION, 0),
(CONF_SET_PERCENTAGE_ACTION, FanEntityFeature.SET_SPEED),
(CONF_SET_PRESET_MODE_ACTION, FanEntityFeature.PRESET_MODE),
(CONF_SET_OSCILLATING_ACTION, FanEntityFeature.OSCILLATE),
(CONF_SET_DIRECTION_ACTION, FanEntityFeature.DIRECTION),
):
# Scripts can be an empty list, therefore we need to check for None
if (action_config := config.get(action_id)) is not None:
self.add_script(action_id, action_config, name, DOMAIN)
self._attr_supported_features |= supported_feature
self._state: bool | None = False
self._percentage: int | None = None
@ -172,19 +176,6 @@ class TemplateFan(TemplateEntity, FanEntity):
# List of valid preset modes
self._preset_modes: list[str] | None = config.get(CONF_PRESET_MODES)
if self._percentage_template:
self._attr_supported_features |= FanEntityFeature.SET_SPEED
if self._preset_mode_template and self._preset_modes:
self._attr_supported_features |= FanEntityFeature.PRESET_MODE
if self._oscillating_template:
self._attr_supported_features |= FanEntityFeature.OSCILLATE
if self._direction_template:
self._attr_supported_features |= FanEntityFeature.DIRECTION
self._attr_supported_features |= (
FanEntityFeature.TURN_OFF | FanEntityFeature.TURN_ON
)
self._attr_assumed_state = self._template is None
@property
@ -270,6 +261,8 @@ class TemplateFan(TemplateEntity, FanEntity):
if self._template is None:
self._state = percentage != 0
if self._template is None or self._percentage_template is None:
self.async_write_ha_state()
async def async_set_preset_mode(self, preset_mode: str) -> None:
@ -285,32 +278,39 @@ class TemplateFan(TemplateEntity, FanEntity):
if self._template is None:
self._state = True
if self._template is None or self._preset_mode_template is None:
self.async_write_ha_state()
async def async_oscillate(self, oscillating: bool) -> None:
"""Set oscillation of the fan."""
if (script := self._action_scripts.get(CONF_SET_OSCILLATING_ACTION)) is None:
return
self._oscillating = oscillating
await self.async_run_script(
script,
run_variables={ATTR_OSCILLATING: self.oscillating},
context=self._context,
)
if (
script := self._action_scripts.get(CONF_SET_OSCILLATING_ACTION)
) is not None:
await self.async_run_script(
script,
run_variables={ATTR_OSCILLATING: self.oscillating},
context=self._context,
)
if self._oscillating_template is None:
self.async_write_ha_state()
async def async_set_direction(self, direction: str) -> None:
"""Set the direction of the fan."""
if (script := self._action_scripts.get(CONF_SET_DIRECTION_ACTION)) is None:
return
if direction in _VALID_DIRECTIONS:
self._direction = direction
await self.async_run_script(
script,
run_variables={ATTR_DIRECTION: direction},
context=self._context,
)
if (
script := self._action_scripts.get(CONF_SET_DIRECTION_ACTION)
) is not None:
await self.async_run_script(
script,
run_variables={ATTR_DIRECTION: direction},
context=self._context,
)
if self._direction_template is None:
self.async_write_ha_state()
else:
_LOGGER.error(
"Received invalid direction: %s for entity %s. Expected: %s",

View File

@ -1,9 +1,12 @@
"""The tests for the Template fan platform."""
from typing import Any
import pytest
import voluptuous as vol
from homeassistant import setup
from homeassistant.components import fan
from homeassistant.components.fan import (
ATTR_DIRECTION,
ATTR_OSCILLATING,
@ -17,11 +20,15 @@ from homeassistant.components.fan import (
)
from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.setup import async_setup_component
from .conftest import ConfigurationStyle
from tests.common import assert_setup_component
from tests.components.fan import common
_TEST_FAN = "fan.test_fan"
_TEST_OBJECT_ID = "test_fan"
_TEST_FAN = f"fan.{_TEST_OBJECT_ID}"
# Represent for fan's state
_STATE_INPUT_BOOLEAN = "input_boolean.state"
# Represent for fan's state
@ -36,6 +43,169 @@ _OSC_INPUT = "input_select.osc"
_DIRECTION_INPUT_SELECT = "input_select.direction"
OPTIMISTIC_ON_OFF_CONFIG = {
"turn_on": {
"service": "test.automation",
"data": {
"action": "turn_on",
"caller": "{{ this.entity_id }}",
},
},
"turn_off": {
"service": "test.automation",
"data": {
"action": "turn_off",
"caller": "{{ this.entity_id }}",
},
},
}
PERCENTAGE_ACTION = {
"set_percentage": {
"action": "test.automation",
"data": {
"action": "set_percentage",
"percentage": "{{ percentage }}",
"caller": "{{ this.entity_id }}",
},
},
}
OPTIMISTIC_PERCENTAGE_CONFIG = {
**OPTIMISTIC_ON_OFF_CONFIG,
**PERCENTAGE_ACTION,
}
PRESET_MODE_ACTION = {
"set_preset_mode": {
"action": "test.automation",
"data": {
"action": "set_preset_mode",
"preset_mode": "{{ preset_mode }}",
"caller": "{{ this.entity_id }}",
},
},
}
OPTIMISTIC_PRESET_MODE_CONFIG = {
**OPTIMISTIC_ON_OFF_CONFIG,
**PRESET_MODE_ACTION,
}
OPTIMISTIC_PRESET_MODE_CONFIG2 = {
**OPTIMISTIC_PRESET_MODE_CONFIG,
"preset_modes": ["auto", "low", "medium", "high"],
}
OSCILLATE_ACTION = {
"set_oscillating": {
"action": "test.automation",
"data": {
"action": "set_oscillating",
"oscillating": "{{ oscillating }}",
"caller": "{{ this.entity_id }}",
},
},
}
OPTIMISTIC_OSCILLATE_CONFIG = {
**OPTIMISTIC_ON_OFF_CONFIG,
**OSCILLATE_ACTION,
}
DIRECTION_ACTION = {
"set_direction": {
"action": "test.automation",
"data": {
"action": "set_direction",
"direction": "{{ direction }}",
"caller": "{{ this.entity_id }}",
},
},
}
OPTIMISTIC_DIRECTION_CONFIG = {
**OPTIMISTIC_ON_OFF_CONFIG,
**DIRECTION_ACTION,
}
async def async_setup_legacy_format(
hass: HomeAssistant, count: int, light_config: dict[str, Any]
) -> None:
"""Do setup of fan integration via legacy format."""
config = {"fan": {"platform": "template", "fans": light_config}}
with assert_setup_component(count, fan.DOMAIN):
assert await async_setup_component(
hass,
fan.DOMAIN,
config,
)
await hass.async_block_till_done()
await hass.async_start()
await hass.async_block_till_done()
async def async_setup_legacy_format_with_attribute(
hass: HomeAssistant,
count: int,
attribute: str,
attribute_template: str,
extra_config: dict,
) -> None:
"""Do setup of a legacy fan that has a single templated attribute."""
extra = {attribute: attribute_template} if attribute and attribute_template else {}
await async_setup_legacy_format(
hass,
count,
{
_TEST_OBJECT_ID: {
**extra_config,
"value_template": "{{ 1 == 1 }}",
**extra,
}
},
)
@pytest.fixture
async def setup_fan(
hass: HomeAssistant,
count: int,
style: ConfigurationStyle,
light_config: dict[str, Any],
) -> None:
"""Do setup of fan integration."""
if style == ConfigurationStyle.LEGACY:
await async_setup_legacy_format(hass, count, light_config)
@pytest.fixture
async def setup_test_fan_with_extra_config(
hass: HomeAssistant,
count: int,
style: ConfigurationStyle,
fan_config: dict[str, Any],
extra_config: dict[str, Any],
) -> None:
"""Do setup of fan integration."""
config = {_TEST_OBJECT_ID: {**fan_config, **extra_config}}
if style == ConfigurationStyle.LEGACY:
await async_setup_legacy_format(hass, count, config)
@pytest.fixture
async def setup_optimistic_fan_attribute(
hass: HomeAssistant,
count: int,
style: ConfigurationStyle,
extra_config: dict,
) -> None:
"""Do setup of a non-optimistic fan with an optimistic attribute."""
if style == ConfigurationStyle.LEGACY:
await async_setup_legacy_format_with_attribute(
hass, count, "", "", extra_config
)
@pytest.mark.parametrize(("count", "domain"), [(1, FAN_DOMAIN)])
@pytest.mark.parametrize(
"config",
@ -123,28 +293,21 @@ async def test_wrong_template_config(hass: HomeAssistant) -> None:
"platform": "template",
"fans": {
"test_fan": {
"value_template": """
{% if is_state('input_boolean.state', 'True') %}
{{ 'on' }}
{% else %}
{{ 'off' }}
{% endif %}
""",
"value_template": "{{ is_state('input_boolean.state', 'True') }}",
"percentage_template": (
"{{ states('input_number.percentage') }}"
),
**OPTIMISTIC_ON_OFF_CONFIG,
**PERCENTAGE_ACTION,
"preset_mode_template": (
"{{ states('input_select.preset_mode') }}"
),
**PRESET_MODE_ACTION,
"oscillating_template": "{{ states('input_select.osc') }}",
**OSCILLATE_ACTION,
"direction_template": "{{ states('input_select.direction') }}",
**DIRECTION_ACTION,
"speed_count": "3",
"set_percentage": {
"service": "script.fans_set_speed",
"data_template": {"percentage": "{{ percentage }}"},
},
"turn_on": {"service": "script.fan_on"},
"turn_off": {"service": "script.fan_off"},
}
},
}
@ -188,8 +351,7 @@ async def test_templates_with_entities(hass: HomeAssistant) -> None:
"test_fan": {
"value_template": "{{ 'on' }}",
"percentage_template": "{{ states('sensor.percentage') }}",
"turn_on": {"service": "script.fan_on"},
"turn_off": {"service": "script.fan_off"},
**OPTIMISTIC_PERCENTAGE_CONFIG,
},
},
}
@ -215,8 +377,7 @@ async def test_templates_with_entities(hass: HomeAssistant) -> None:
"preset_mode_template": (
"{{ states('sensor.preset_mode') }}"
),
"turn_on": {"service": "script.fan_on"},
"turn_off": {"service": "script.fan_off"},
**OPTIMISTIC_PRESET_MODE_CONFIG,
},
},
}
@ -284,8 +445,7 @@ async def test_availability_template_with_entities(hass: HomeAssistant) -> None:
"fans": {
"test_fan": {
"value_template": "{{ 'unavailable' }}",
"turn_on": {"service": "script.fan_on"},
"turn_off": {"service": "script.fan_off"},
**OPTIMISTIC_ON_OFF_CONFIG,
}
},
}
@ -299,11 +459,12 @@ async def test_availability_template_with_entities(hass: HomeAssistant) -> None:
"fans": {
"test_fan": {
"value_template": "{{ 'on' }}",
"oscillating_template": "{{ 'unavailable' }}",
"direction_template": "{{ 'unavailable' }}",
"percentage_template": "{{ 0 }}",
"turn_on": {"service": "script.fan_on"},
"turn_off": {"service": "script.fan_off"},
**OPTIMISTIC_PERCENTAGE_CONFIG,
"oscillating_template": "{{ 'unavailable' }}",
**OSCILLATE_ACTION,
"direction_template": "{{ 'unavailable' }}",
**DIRECTION_ACTION,
}
},
}
@ -317,11 +478,12 @@ async def test_availability_template_with_entities(hass: HomeAssistant) -> None:
"fans": {
"test_fan": {
"value_template": "{{ 'on' }}",
"oscillating_template": "{{ 1 == 1 }}",
"direction_template": "{{ 'forward' }}",
"percentage_template": "{{ 66 }}",
"turn_on": {"service": "script.fan_on"},
"turn_off": {"service": "script.fan_off"},
**OPTIMISTIC_PERCENTAGE_CONFIG,
"oscillating_template": "{{ 1 == 1 }}",
**OSCILLATE_ACTION,
"direction_template": "{{ 'forward' }}",
**DIRECTION_ACTION,
}
},
}
@ -335,11 +497,12 @@ async def test_availability_template_with_entities(hass: HomeAssistant) -> None:
"fans": {
"test_fan": {
"value_template": "{{ 'abc' }}",
"oscillating_template": "{{ 'xyz' }}",
"direction_template": "{{ 'right' }}",
"percentage_template": "{{ 0 }}",
"turn_on": {"service": "script.fan_on"},
"turn_off": {"service": "script.fan_off"},
**OPTIMISTIC_PERCENTAGE_CONFIG,
"oscillating_template": "{{ 'xyz' }}",
**OSCILLATE_ACTION,
"direction_template": "{{ 'right' }}",
**DIRECTION_ACTION,
}
},
}
@ -541,77 +704,18 @@ async def test_increase_decrease_speed(
_verify(hass, state, value, None, None, None)
async def test_no_value_template(hass: HomeAssistant, calls: list[ServiceCall]) -> None:
async def test_optimistic_state(hass: HomeAssistant, calls: list[ServiceCall]) -> None:
"""Test a fan without a value_template."""
await _register_fan_sources(hass)
with assert_setup_component(1, "fan"):
test_fan_config = {
"preset_mode_template": "{{ states('input_select.preset_mode') }}",
**OPTIMISTIC_ON_OFF_CONFIG,
"preset_modes": ["auto"],
"percentage_template": "{{ states('input_number.percentage') }}",
"oscillating_template": "{{ states('input_select.osc') }}",
"direction_template": "{{ states('input_select.direction') }}",
"turn_on": [
{
"service": "input_boolean.turn_on",
"entity_id": _STATE_INPUT_BOOLEAN,
},
{
"service": "test.automation",
"data_template": {
"action": "turn_on",
"caller": "{{ this.entity_id }}",
},
},
],
"turn_off": [
{
"service": "input_boolean.turn_off",
"entity_id": _STATE_INPUT_BOOLEAN,
},
{
"service": "test.automation",
"data_template": {
"action": "turn_off",
"caller": "{{ this.entity_id }}",
},
},
],
"set_preset_mode": [
{
"service": "input_select.select_option",
"data_template": {
"entity_id": _PRESET_MODE_INPUT_SELECT,
"option": "{{ preset_mode }}",
},
},
{
"service": "test.automation",
"data_template": {
"action": "set_preset_mode",
"caller": "{{ this.entity_id }}",
"option": "{{ preset_mode }}",
},
},
],
"set_percentage": [
{
"service": "input_number.set_value",
"data_template": {
"entity_id": _PERCENTAGE_INPUT_NUMBER,
"value": "{{ percentage }}",
},
},
{
"service": "test.automation",
"data_template": {
"action": "set_value",
"caller": "{{ this.entity_id }}",
"value": "{{ percentage }}",
},
},
],
**PRESET_MODE_ACTION,
**PERCENTAGE_ACTION,
**OSCILLATE_ACTION,
**DIRECTION_ACTION,
}
assert await setup.async_setup_component(
hass,
@ -624,32 +728,127 @@ async def test_no_value_template(hass: HomeAssistant, calls: list[ServiceCall])
await hass.async_block_till_done()
await common.async_turn_on(hass, _TEST_FAN)
_verify(hass, STATE_ON, 0, None, None, "auto")
_verify(hass, STATE_ON)
assert len(calls) == 1
assert calls[-1].data["action"] == "turn_on"
assert calls[-1].data["caller"] == _TEST_FAN
await common.async_turn_off(hass, _TEST_FAN)
_verify(hass, STATE_OFF, 0, None, None, "auto")
_verify(hass, STATE_OFF)
assert len(calls) == 2
assert calls[-1].data["action"] == "turn_off"
assert calls[-1].data["caller"] == _TEST_FAN
percent = 100
await common.async_set_percentage(hass, _TEST_FAN, percent)
assert int(float(hass.states.get(_PERCENTAGE_INPUT_NUMBER).state)) == percent
_verify(hass, STATE_ON, percent, None, None, "auto")
_verify(hass, STATE_ON, percent)
assert len(calls) == 3
assert calls[-1].data["action"] == "set_percentage"
assert calls[-1].data["percentage"] == 100
assert calls[-1].data["caller"] == _TEST_FAN
await common.async_turn_off(hass, _TEST_FAN)
_verify(hass, STATE_OFF, percent, None, None, "auto")
_verify(hass, STATE_OFF, percent)
assert len(calls) == 4
assert calls[-1].data["action"] == "turn_off"
assert calls[-1].data["caller"] == _TEST_FAN
preset = "auto"
await common.async_set_preset_mode(hass, _TEST_FAN, preset)
assert hass.states.get(_PRESET_MODE_INPUT_SELECT).state == preset
_verify(hass, STATE_ON, percent, None, None, preset)
assert len(calls) == 5
assert calls[-1].data["action"] == "set_preset_mode"
assert calls[-1].data["preset_mode"] == preset
assert calls[-1].data["caller"] == _TEST_FAN
await common.async_turn_off(hass, _TEST_FAN)
_verify(hass, STATE_OFF, percent, None, None, preset)
await common.async_set_direction(hass, _TEST_FAN, True)
_verify(hass, STATE_OFF, percent, None, None, preset)
assert len(calls) == 6
assert calls[-1].data["action"] == "turn_off"
assert calls[-1].data["caller"] == _TEST_FAN
await common.async_set_direction(hass, _TEST_FAN, DIRECTION_FORWARD)
_verify(hass, STATE_OFF, percent, None, DIRECTION_FORWARD, preset)
assert len(calls) == 7
assert calls[-1].data["action"] == "set_direction"
assert calls[-1].data["direction"] == DIRECTION_FORWARD
assert calls[-1].data["caller"] == _TEST_FAN
await common.async_oscillate(hass, _TEST_FAN, True)
_verify(hass, STATE_OFF, percent, None, None, preset)
_verify(hass, STATE_OFF, percent, True, DIRECTION_FORWARD, preset)
assert len(calls) == 8
assert calls[-1].data["action"] == "set_oscillating"
assert calls[-1].data["oscillating"] is True
assert calls[-1].data["caller"] == _TEST_FAN
@pytest.mark.parametrize("count", [1])
@pytest.mark.parametrize("style", [ConfigurationStyle.LEGACY])
@pytest.mark.parametrize(
("extra_config", "attribute", "action", "verify_attr", "coro", "value"),
[
(
OPTIMISTIC_PERCENTAGE_CONFIG,
"percentage",
"set_percentage",
"expected_percentage",
common.async_set_percentage,
50,
),
(
OPTIMISTIC_PRESET_MODE_CONFIG2,
"preset_mode",
"set_preset_mode",
"expected_preset_mode",
common.async_set_preset_mode,
"auto",
),
(
OPTIMISTIC_OSCILLATE_CONFIG,
"oscillating",
"set_oscillating",
"expected_oscillating",
common.async_oscillate,
True,
),
(
OPTIMISTIC_DIRECTION_CONFIG,
"direction",
"set_direction",
"expected_direction",
common.async_set_direction,
DIRECTION_FORWARD,
),
],
)
async def test_optimistic_attributes(
hass: HomeAssistant,
attribute: str,
action: str,
verify_attr: str,
coro,
value: Any,
setup_optimistic_fan_attribute,
calls: list[ServiceCall],
) -> None:
"""Test setting percentage with optimistic template."""
await coro(hass, _TEST_FAN, value)
_verify(hass, STATE_ON, **{verify_attr: value})
assert len(calls) == 1
assert calls[-1].data["action"] == action
assert calls[-1].data[attribute] == value
assert calls[-1].data["caller"] == _TEST_FAN
async def test_increase_decrease_speed_default_speed_count(
@ -702,10 +901,10 @@ async def test_set_invalid_osc(hass: HomeAssistant, calls: list[ServiceCall]) ->
def _verify(
hass: HomeAssistant,
expected_state: str,
expected_percentage: int | None,
expected_oscillating: bool | None,
expected_direction: str | None,
expected_preset_mode: str | None,
expected_percentage: int | None = None,
expected_oscillating: bool | None = None,
expected_direction: str | None = None,
expected_preset_mode: str | None = None,
) -> None:
"""Verify fan's state, speed and osc."""
state = hass.states.get(_TEST_FAN)
@ -1093,3 +1292,57 @@ async def test_implemented_preset_mode(hass: HomeAssistant) -> None:
attributes = state.attributes
assert attributes.get("percentage") is None
assert attributes.get("supported_features") & FanEntityFeature.PRESET_MODE
@pytest.mark.parametrize("count", [1])
@pytest.mark.parametrize(
("style", "fan_config"),
[
(
ConfigurationStyle.LEGACY,
{
"turn_on": [],
"turn_off": [],
},
),
],
)
@pytest.mark.parametrize(
("extra_config", "supported_features"),
[
(
{
"set_percentage": [],
},
FanEntityFeature.SET_SPEED,
),
(
{
"set_preset_mode": [],
},
FanEntityFeature.PRESET_MODE,
),
(
{
"set_oscillating": [],
},
FanEntityFeature.OSCILLATE,
),
(
{
"set_direction": [],
},
FanEntityFeature.DIRECTION,
),
],
)
async def test_empty_action_config(
hass: HomeAssistant,
supported_features: FanEntityFeature,
setup_test_fan_with_extra_config,
) -> None:
"""Test configuration with empty script."""
state = hass.states.get(_TEST_FAN)
assert state.attributes["supported_features"] == (
FanEntityFeature.TURN_OFF | FanEntityFeature.TURN_ON | supported_features
)