"""The tests for the Template fan platform.""" from typing import Any import pytest import voluptuous as vol from homeassistant.components import fan, template from homeassistant.components.fan import ( ATTR_DIRECTION, ATTR_OSCILLATING, ATTR_PERCENTAGE, ATTR_PRESET_MODE, DIRECTION_FORWARD, DIRECTION_REVERSE, FanEntityFeature, NotValidPresetModeError, ) from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import entity_registry as er 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_OBJECT_ID = "test_fan" TEST_ENTITY_ID = f"fan.{TEST_OBJECT_ID}" # Represent for fan's state _STATE_INPUT_BOOLEAN = "input_boolean.state" # Represent for fan's state _STATE_AVAILABILITY_BOOLEAN = "availability_boolean.state" OPTIMISTIC_ON_OFF_ACTIONS = { "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 }}", }, }, } NAMED_ON_OFF_ACTIONS = { **OPTIMISTIC_ON_OFF_ACTIONS, "name": TEST_OBJECT_ID, } PERCENTAGE_ACTION = { "set_percentage": { "action": "test.automation", "data": { "action": "set_percentage", "percentage": "{{ percentage }}", "caller": "{{ this.entity_id }}", }, }, } OPTIMISTIC_PERCENTAGE_CONFIG = { **OPTIMISTIC_ON_OFF_ACTIONS, **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_ACTIONS, **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_ACTIONS, **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_ACTIONS, **DIRECTION_ACTION, } UNIQUE_ID_CONFIG = { **OPTIMISTIC_ON_OFF_ACTIONS, "unique_id": "not-so-unique-anymore", } def _verify( hass: HomeAssistant, expected_state: str, 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_ENTITY_ID) attributes = state.attributes assert state.state == str(expected_state) assert attributes.get(ATTR_PERCENTAGE) == expected_percentage assert attributes.get(ATTR_OSCILLATING) == expected_oscillating assert attributes.get(ATTR_DIRECTION) == expected_direction assert attributes.get(ATTR_PRESET_MODE) == expected_preset_mode async def async_setup_legacy_format( hass: HomeAssistant, count: int, fan_config: dict[str, Any] ) -> None: """Do setup of fan integration via legacy format.""" config = {"fan": {"platform": "template", "fans": fan_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_modern_format( hass: HomeAssistant, count: int, fan_config: dict[str, Any] ) -> None: """Do setup of fan integration via modern format.""" config = {"template": {"fan": fan_config}} with assert_setup_component(count, template.DOMAIN): assert await async_setup_component( hass, template.DOMAIN, config, ) await hass.async_block_till_done() await hass.async_start() await hass.async_block_till_done() async def async_setup_legacy_named_fan( hass: HomeAssistant, count: int, fan_config: dict[str, Any] ): """Do setup of a named fan via legacy format.""" await async_setup_legacy_format(hass, count, {TEST_OBJECT_ID: fan_config}) async def async_setup_modern_named_fan( hass: HomeAssistant, count: int, fan_config: dict[str, Any] ): """Do setup of a named fan via legacy format.""" await async_setup_modern_format(hass, count, {"name": TEST_OBJECT_ID, **fan_config}) 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, } }, ) async def async_setup_modern_format_with_attribute( hass: HomeAssistant, count: int, attribute: str, attribute_template: str, extra_config: dict, ) -> None: """Do setup of a modern fan that has a single templated attribute.""" extra = {attribute: attribute_template} if attribute and attribute_template else {} await async_setup_modern_format( hass, count, { "name": TEST_OBJECT_ID, **extra_config, "state": "{{ 1 == 1 }}", **extra, }, ) @pytest.fixture async def setup_fan( hass: HomeAssistant, count: int, style: ConfigurationStyle, fan_config: dict[str, Any], ) -> None: """Do setup of fan integration.""" if style == ConfigurationStyle.LEGACY: await async_setup_legacy_format(hass, count, fan_config) elif style == ConfigurationStyle.MODERN: await async_setup_modern_format(hass, count, fan_config) @pytest.fixture async def setup_named_fan( hass: HomeAssistant, count: int, style: ConfigurationStyle, fan_config: dict[str, Any], ) -> None: """Do setup of fan integration.""" if style == ConfigurationStyle.LEGACY: await async_setup_legacy_named_fan(hass, count, fan_config) elif style == ConfigurationStyle.MODERN: await async_setup_modern_named_fan(hass, count, fan_config) @pytest.fixture async def setup_state_fan( hass: HomeAssistant, count: int, style: ConfigurationStyle, state_template: str, ): """Do setup of fan integration using a state template.""" if style == ConfigurationStyle.LEGACY: await async_setup_legacy_format( hass, count, { TEST_OBJECT_ID: { **OPTIMISTIC_ON_OFF_ACTIONS, "value_template": state_template, } }, ) elif style == ConfigurationStyle.MODERN: await async_setup_modern_format( hass, count, { **NAMED_ON_OFF_ACTIONS, "state": state_template, }, ) @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.""" if style == ConfigurationStyle.LEGACY: await async_setup_legacy_format( hass, count, {TEST_OBJECT_ID: {**fan_config, **extra_config}} ) elif style == ConfigurationStyle.MODERN: await async_setup_modern_format( hass, count, {"name": TEST_OBJECT_ID, **fan_config, **extra_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 ) elif style == ConfigurationStyle.MODERN: await async_setup_modern_format_with_attribute( hass, count, "", "", extra_config ) @pytest.fixture async def setup_single_attribute_state_fan( hass: HomeAssistant, count: int, style: ConfigurationStyle, attribute: str, attribute_template: str, state_template: str, extra_config: dict, ) -> None: """Do setup of fan integration testing a single attribute.""" extra = {attribute: attribute_template} if attribute and attribute_template else {} if style == ConfigurationStyle.LEGACY: await async_setup_legacy_format( hass, count, { TEST_OBJECT_ID: { **OPTIMISTIC_ON_OFF_ACTIONS, "value_template": state_template, **extra, **extra_config, } }, ) elif style == ConfigurationStyle.MODERN: await async_setup_modern_format( hass, count, { **NAMED_ON_OFF_ACTIONS, "state": state_template, **extra, **extra_config, }, ) @pytest.mark.parametrize(("count", "state_template"), [(1, "{{ 'on' }}")]) @pytest.mark.parametrize( "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] ) @pytest.mark.usefixtures("setup_state_fan") async def test_missing_optional_config(hass: HomeAssistant) -> None: """Test: missing optional template is ok.""" _verify(hass, STATE_ON, None, None, None, None) @pytest.mark.parametrize("count", [0]) @pytest.mark.parametrize( "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] ) @pytest.mark.parametrize( "fan_config", [ { "value_template": "{{ 'on' }}", "turn_off": {"service": "script.fan_off"}, }, { "value_template": "{{ 'on' }}", "turn_on": {"service": "script.fan_on"}, }, ], ) @pytest.mark.usefixtures("setup_fan") async def test_wrong_template_config(hass: HomeAssistant) -> None: """Test: missing 'turn_on' or 'turn_off' will fail.""" assert hass.states.async_all("fan") == [] @pytest.mark.parametrize( ("count", "state_template"), [(1, "{{ is_state('input_boolean.state', 'on') }}")] ) @pytest.mark.parametrize( "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] ) @pytest.mark.usefixtures("setup_state_fan") async def test_state_template(hass: HomeAssistant) -> None: """Test state template.""" _verify(hass, STATE_OFF, None, None, None, None) hass.states.async_set(_STATE_INPUT_BOOLEAN, STATE_ON) await hass.async_block_till_done() _verify(hass, STATE_ON, None, None, None, None) hass.states.async_set(_STATE_INPUT_BOOLEAN, STATE_OFF) await hass.async_block_till_done() _verify(hass, STATE_OFF, None, None, None, None) @pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( ("state_template", "expected"), [ ("{{ True }}", STATE_ON), ("{{ False }}", STATE_OFF), ("{{ x - 1 }}", STATE_UNAVAILABLE), ("{{ 7.45 }}", STATE_OFF), ], ) @pytest.mark.parametrize( "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] ) @pytest.mark.usefixtures("setup_state_fan") async def test_state_template_states(hass: HomeAssistant, expected: str) -> None: """Test state template.""" _verify(hass, expected, None, None, None, None) @pytest.mark.parametrize( ("count", "state_template", "attribute_template", "extra_config"), [ ( 1, "{{ 1 == 1}}", "{% if states.input_boolean.state.state %}/local/switch.png{% endif %}", {}, ) ], ) @pytest.mark.parametrize( ("style", "attribute"), [ (ConfigurationStyle.MODERN, "picture"), ], ) @pytest.mark.usefixtures("setup_single_attribute_state_fan") async def test_picture_template(hass: HomeAssistant) -> None: """Test picture template.""" state = hass.states.get(TEST_ENTITY_ID) assert state.attributes.get("entity_picture") in ("", None) hass.states.async_set(_STATE_INPUT_BOOLEAN, STATE_ON) await hass.async_block_till_done() state = hass.states.get(TEST_ENTITY_ID) assert state.attributes["entity_picture"] == "/local/switch.png" @pytest.mark.parametrize( ("count", "state_template", "attribute_template", "extra_config"), [ ( 1, "{{ 1 == 1}}", "{% if states.input_boolean.state.state %}mdi:eye{% endif %}", {}, ) ], ) @pytest.mark.parametrize( ("style", "attribute"), [ (ConfigurationStyle.MODERN, "icon"), ], ) @pytest.mark.usefixtures("setup_single_attribute_state_fan") async def test_icon_template(hass: HomeAssistant) -> None: """Test icon template.""" state = hass.states.get(TEST_ENTITY_ID) assert state.attributes.get("icon") in ("", None) hass.states.async_set(_STATE_INPUT_BOOLEAN, STATE_ON) await hass.async_block_till_done() state = hass.states.get(TEST_ENTITY_ID) assert state.attributes["icon"] == "mdi:eye" @pytest.mark.parametrize( ("count", "state_template", "attribute_template", "extra_config"), [ ( 1, "{{ 1 == 1 }}", "{{ states('sensor.percentage') }}", PERCENTAGE_ACTION, ) ], ) @pytest.mark.parametrize( ("style", "attribute"), [ (ConfigurationStyle.LEGACY, "percentage_template"), (ConfigurationStyle.MODERN, "percentage"), ], ) @pytest.mark.parametrize( ("percent", "expected"), [ ("0", 0), ("33", 33), ("invalid", 0), ("5000", 0), ("100", 100), ], ) @pytest.mark.usefixtures("setup_single_attribute_state_fan") async def test_percentage_template( hass: HomeAssistant, percent: str, expected: int, calls: list[ServiceCall] ) -> None: """Test templates with fan percentages from other entities.""" hass.states.async_set("sensor.percentage", percent) await hass.async_block_till_done() _verify(hass, STATE_ON, expected, None, None, None) @pytest.mark.parametrize( ("count", "state_template", "attribute_template", "extra_config"), [ ( 1, "{{ 1 == 1 }}", "{{ states('sensor.preset_mode') }}", {"preset_modes": ["auto", "smart"], **PRESET_MODE_ACTION}, ) ], ) @pytest.mark.parametrize( ("style", "attribute"), [ (ConfigurationStyle.LEGACY, "preset_mode_template"), (ConfigurationStyle.MODERN, "preset_mode"), ], ) @pytest.mark.parametrize( ("preset_mode", "expected"), [ ("0", None), ("invalid", None), ("auto", "auto"), ("smart", "smart"), ], ) @pytest.mark.usefixtures("setup_single_attribute_state_fan") async def test_preset_mode_template( hass: HomeAssistant, preset_mode: str, expected: int ) -> None: """Test preset_mode template.""" hass.states.async_set("sensor.preset_mode", preset_mode) await hass.async_block_till_done() _verify(hass, STATE_ON, None, None, None, expected) @pytest.mark.parametrize( ("count", "state_template", "attribute_template", "extra_config"), [ ( 1, "{{ 1 == 1 }}", "{{ is_state('binary_sensor.oscillating', 'on') }}", OSCILLATE_ACTION, ) ], ) @pytest.mark.parametrize( ("style", "attribute"), [ (ConfigurationStyle.LEGACY, "oscillating_template"), (ConfigurationStyle.MODERN, "oscillating"), ], ) @pytest.mark.parametrize( ("oscillating", "expected"), [ (STATE_ON, True), (STATE_OFF, False), ], ) @pytest.mark.usefixtures("setup_single_attribute_state_fan") async def test_oscillating_template( hass: HomeAssistant, oscillating: str, expected: bool | None ) -> None: """Test oscillating template.""" hass.states.async_set("binary_sensor.oscillating", oscillating) await hass.async_block_till_done() _verify(hass, STATE_ON, None, expected, None, None) @pytest.mark.parametrize( ("count", "state_template", "attribute_template", "extra_config"), [ ( 1, "{{ 1 == 1 }}", "{{ states('sensor.direction') }}", DIRECTION_ACTION, ) ], ) @pytest.mark.parametrize( ("style", "attribute"), [ (ConfigurationStyle.LEGACY, "direction_template"), (ConfigurationStyle.MODERN, "direction"), ], ) @pytest.mark.parametrize( ("direction", "expected"), [ (DIRECTION_FORWARD, DIRECTION_FORWARD), (DIRECTION_REVERSE, DIRECTION_REVERSE), ], ) @pytest.mark.usefixtures("setup_single_attribute_state_fan") async def test_direction_template( hass: HomeAssistant, direction: str, expected: bool | None ) -> None: """Test direction template.""" hass.states.async_set("sensor.direction", direction) await hass.async_block_till_done() _verify(hass, STATE_ON, None, None, expected, None) @pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( ("style", "fan_config"), [ ( ConfigurationStyle.LEGACY, { "availability_template": ( "{{ is_state('availability_boolean.state', 'on') }}" ), "value_template": "{{ 'on' }}", "oscillating_template": "{{ 1 == 1 }}", "direction_template": "{{ 'forward' }}", "turn_on": {"service": "script.fan_on"}, "turn_off": {"service": "script.fan_off"}, }, ), ( ConfigurationStyle.MODERN, { "availability": ("{{ is_state('availability_boolean.state', 'on') }}"), "state": "{{ 'on' }}", "oscillating": "{{ 1 == 1 }}", "direction": "{{ 'forward' }}", "turn_on": {"service": "script.fan_on"}, "turn_off": {"service": "script.fan_off"}, }, ), ], ) @pytest.mark.usefixtures("setup_named_fan") async def test_availability_template_with_entities(hass: HomeAssistant) -> None: """Test availability tempalates with values from other entities.""" for state, test_assert in ((STATE_ON, True), (STATE_OFF, False)): hass.states.async_set(_STATE_AVAILABILITY_BOOLEAN, state) await hass.async_block_till_done() assert ( hass.states.get(TEST_ENTITY_ID).state != STATE_UNAVAILABLE ) == test_assert @pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( ("style", "fan_config", "states"), [ ( ConfigurationStyle.LEGACY, { "value_template": "{{ 'unavailable' }}", **OPTIMISTIC_ON_OFF_ACTIONS, }, [STATE_OFF, None, None, None], ), ( ConfigurationStyle.MODERN, { "state": "{{ 'unavailable' }}", **OPTIMISTIC_ON_OFF_ACTIONS, }, [STATE_OFF, None, None, None], ), ( ConfigurationStyle.LEGACY, { "value_template": "{{ 'on' }}", "percentage_template": "{{ 0 }}", **OPTIMISTIC_PERCENTAGE_CONFIG, "oscillating_template": "{{ 'unavailable' }}", **OSCILLATE_ACTION, "direction_template": "{{ 'unavailable' }}", **DIRECTION_ACTION, }, [STATE_ON, 0, None, None], ), ( ConfigurationStyle.MODERN, { "state": "{{ 'on' }}", "percentage": "{{ 0 }}", **OPTIMISTIC_PERCENTAGE_CONFIG, "oscillating": "{{ 'unavailable' }}", **OSCILLATE_ACTION, "direction": "{{ 'unavailable' }}", **DIRECTION_ACTION, }, [STATE_ON, 0, None, None], ), ( ConfigurationStyle.LEGACY, { "value_template": "{{ 'on' }}", "percentage_template": "{{ 66 }}", **OPTIMISTIC_PERCENTAGE_CONFIG, "oscillating_template": "{{ 1 == 1 }}", **OSCILLATE_ACTION, "direction_template": "{{ 'forward' }}", **DIRECTION_ACTION, }, [STATE_ON, 66, True, DIRECTION_FORWARD], ), ( ConfigurationStyle.MODERN, { "state": "{{ 'on' }}", "percentage": "{{ 66 }}", **OPTIMISTIC_PERCENTAGE_CONFIG, "oscillating": "{{ 1 == 1 }}", **OSCILLATE_ACTION, "direction": "{{ 'forward' }}", **DIRECTION_ACTION, }, [STATE_ON, 66, True, DIRECTION_FORWARD], ), ( ConfigurationStyle.LEGACY, { "value_template": "{{ 'abc' }}", "percentage_template": "{{ 0 }}", **OPTIMISTIC_PERCENTAGE_CONFIG, "oscillating_template": "{{ 'xyz' }}", **OSCILLATE_ACTION, "direction_template": "{{ 'right' }}", **DIRECTION_ACTION, }, [STATE_OFF, 0, None, None], ), ( ConfigurationStyle.MODERN, { "state": "{{ 'abc' }}", "percentage": "{{ 0 }}", **OPTIMISTIC_PERCENTAGE_CONFIG, "oscillating": "{{ 'xyz' }}", **OSCILLATE_ACTION, "direction": "{{ 'right' }}", **DIRECTION_ACTION, }, [STATE_OFF, 0, None, None], ), ], ) @pytest.mark.usefixtures("setup_named_fan") async def test_template_with_unavailable_entities(hass: HomeAssistant, states) -> None: """Test unavailability with value_template.""" _verify(hass, states[0], states[1], states[2], states[3], None) @pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( ("style", "fan_config"), [ ( ConfigurationStyle.LEGACY, { "value_template": "{{ 'on' }}", "availability_template": "{{ x - 12 }}", "preset_mode_template": ("{{ states('input_select.preset_mode') }}"), "oscillating_template": "{{ states('input_select.osc') }}", "direction_template": "{{ states('input_select.direction') }}", "turn_on": {"service": "script.fan_on"}, "turn_off": {"service": "script.fan_off"}, }, ), ( ConfigurationStyle.MODERN, { "state": "{{ 'on' }}", "availability": "{{ x - 12 }}", "preset_mode": ("{{ states('input_select.preset_mode') }}"), "oscillating": "{{ states('input_select.osc') }}", "direction": "{{ states('input_select.direction') }}", "turn_on": {"service": "script.fan_on"}, "turn_off": {"service": "script.fan_off"}, }, ), ], ) @pytest.mark.usefixtures("setup_named_fan") async def test_invalid_availability_template_keeps_component_available( hass: HomeAssistant, caplog_setup_text ) -> None: """Test that an invalid availability keeps the device available.""" assert hass.states.get("fan.test_fan").state != STATE_UNAVAILABLE assert "TemplateError" in caplog_setup_text assert "x" in caplog_setup_text @pytest.mark.parametrize(("count", "extra_config"), [(1, OPTIMISTIC_ON_OFF_ACTIONS)]) @pytest.mark.parametrize( ("style", "fan_config"), [ ( ConfigurationStyle.LEGACY, { "value_template": "{{ 'off' }}", }, ), ( ConfigurationStyle.MODERN, { "state": "{{ 'off' }}", }, ), ], ) @pytest.mark.usefixtures("setup_test_fan_with_extra_config") async def test_on_off(hass: HomeAssistant, calls: list[ServiceCall]) -> None: """Test turn on and turn off.""" state = hass.states.get(TEST_ENTITY_ID) assert state.state == STATE_OFF for expected_calls, (func, action) in enumerate( [ (common.async_turn_on, "turn_on"), (common.async_turn_off, "turn_off"), ] ): await func(hass, TEST_ENTITY_ID) assert len(calls) == expected_calls + 1 assert calls[-1].data["action"] == action assert calls[-1].data["caller"] == TEST_ENTITY_ID @pytest.mark.parametrize( ("count", "extra_config"), [ ( 1, { **OPTIMISTIC_ON_OFF_ACTIONS, **OPTIMISTIC_PRESET_MODE_CONFIG2, **OPTIMISTIC_PERCENTAGE_CONFIG, }, ) ], ) @pytest.mark.parametrize( ("style", "fan_config"), [ ( ConfigurationStyle.LEGACY, { "value_template": "{{ 'off' }}", }, ), ( ConfigurationStyle.MODERN, { "state": "{{ 'off' }}", }, ), ], ) @pytest.mark.usefixtures("setup_test_fan_with_extra_config") async def test_on_with_extra_attributes( hass: HomeAssistant, calls: list[ServiceCall] ) -> None: """Test turn on and turn off.""" state = hass.states.get(TEST_ENTITY_ID) assert state.state == STATE_OFF await common.async_turn_on(hass, TEST_ENTITY_ID, 100) assert len(calls) == 2 assert calls[-2].data["action"] == "turn_on" assert calls[-2].data["caller"] == TEST_ENTITY_ID assert calls[-1].data["action"] == "set_percentage" assert calls[-1].data["caller"] == TEST_ENTITY_ID assert calls[-1].data["percentage"] == 100 await common.async_turn_off(hass, TEST_ENTITY_ID) assert len(calls) == 3 assert calls[-1].data["action"] == "turn_off" assert calls[-1].data["caller"] == TEST_ENTITY_ID await common.async_turn_on(hass, TEST_ENTITY_ID, None, "auto") assert len(calls) == 5 assert calls[-2].data["action"] == "turn_on" assert calls[-2].data["caller"] == TEST_ENTITY_ID assert calls[-1].data["action"] == "set_preset_mode" assert calls[-1].data["caller"] == TEST_ENTITY_ID assert calls[-1].data["preset_mode"] == "auto" await common.async_turn_off(hass, TEST_ENTITY_ID) assert len(calls) == 6 assert calls[-1].data["action"] == "turn_off" assert calls[-1].data["caller"] == TEST_ENTITY_ID await common.async_turn_on(hass, TEST_ENTITY_ID, 50, "high") assert len(calls) == 9 assert calls[-3].data["action"] == "turn_on" assert calls[-3].data["caller"] == TEST_ENTITY_ID assert calls[-2].data["action"] == "set_preset_mode" assert calls[-2].data["caller"] == TEST_ENTITY_ID assert calls[-2].data["preset_mode"] == "high" assert calls[-1].data["action"] == "set_percentage" assert calls[-1].data["caller"] == TEST_ENTITY_ID assert calls[-1].data["percentage"] == 50 await common.async_turn_off(hass, TEST_ENTITY_ID) assert len(calls) == 10 assert calls[-1].data["action"] == "turn_off" assert calls[-1].data["caller"] == TEST_ENTITY_ID @pytest.mark.parametrize( ("count", "extra_config"), [(1, {**OPTIMISTIC_ON_OFF_ACTIONS, **DIRECTION_ACTION})] ) @pytest.mark.parametrize( ("style", "fan_config"), [ ( ConfigurationStyle.LEGACY, { "value_template": "{{ 'on' }}", }, ), ( ConfigurationStyle.MODERN, { "state": "{{ 'on' }}", }, ), ], ) @pytest.mark.usefixtures("setup_test_fan_with_extra_config") async def test_set_invalid_direction_from_initial_stage(hass: HomeAssistant) -> None: """Test set invalid direction when fan is in initial state.""" await common.async_set_direction(hass, TEST_ENTITY_ID, "invalid") _verify(hass, STATE_ON, None, None, None, None) @pytest.mark.parametrize( ("count", "extra_config"), [(1, {**OPTIMISTIC_ON_OFF_ACTIONS, **OSCILLATE_ACTION})] ) @pytest.mark.parametrize( ("style", "fan_config"), [ ( ConfigurationStyle.LEGACY, { "value_template": "{{ 'on' }}", }, ), ( ConfigurationStyle.MODERN, { "state": "{{ 'on' }}", }, ), ], ) @pytest.mark.usefixtures("setup_test_fan_with_extra_config") async def test_set_osc(hass: HomeAssistant, calls: list[ServiceCall]) -> None: """Test set oscillating.""" expected_calls = 0 await common.async_turn_on(hass, TEST_ENTITY_ID) expected_calls += 1 for state in (True, False): await common.async_oscillate(hass, TEST_ENTITY_ID, state) _verify(hass, STATE_ON, None, state, None, None) expected_calls += 1 assert len(calls) == expected_calls assert calls[-1].data["action"] == "set_oscillating" assert calls[-1].data["caller"] == TEST_ENTITY_ID assert calls[-1].data["oscillating"] == state @pytest.mark.parametrize( ("count", "extra_config"), [(1, {**OPTIMISTIC_ON_OFF_ACTIONS, **DIRECTION_ACTION})] ) @pytest.mark.parametrize( ("style", "fan_config"), [ ( ConfigurationStyle.LEGACY, { "value_template": "{{ 'on' }}", }, ), ( ConfigurationStyle.MODERN, { "state": "{{ 'on' }}", }, ), ], ) @pytest.mark.usefixtures("setup_test_fan_with_extra_config") async def test_set_direction(hass: HomeAssistant, calls: list[ServiceCall]) -> None: """Test set valid direction.""" expected_calls = 0 await common.async_turn_on(hass, TEST_ENTITY_ID) expected_calls += 1 for direction in (DIRECTION_FORWARD, DIRECTION_REVERSE): await common.async_set_direction(hass, TEST_ENTITY_ID, direction) _verify(hass, STATE_ON, None, None, direction, None) expected_calls += 1 assert len(calls) == expected_calls assert calls[-1].data["action"] == "set_direction" assert calls[-1].data["caller"] == TEST_ENTITY_ID assert calls[-1].data["direction"] == direction @pytest.mark.parametrize( ("count", "extra_config"), [(1, {**OPTIMISTIC_ON_OFF_ACTIONS, **DIRECTION_ACTION})] ) @pytest.mark.parametrize( ("style", "fan_config"), [ ( ConfigurationStyle.LEGACY, { "value_template": "{{ 'on' }}", }, ), ( ConfigurationStyle.MODERN, { "state": "{{ 'on' }}", }, ), ], ) @pytest.mark.usefixtures("setup_test_fan_with_extra_config") async def test_set_invalid_direction( hass: HomeAssistant, calls: list[ServiceCall] ) -> None: """Test set invalid direction when fan has valid direction.""" expected_calls = 1 for direction in (DIRECTION_FORWARD, "invalid"): await common.async_set_direction(hass, TEST_ENTITY_ID, direction) _verify(hass, STATE_ON, None, None, DIRECTION_FORWARD, None) assert len(calls) == expected_calls assert calls[-1].data["action"] == "set_direction" assert calls[-1].data["caller"] == TEST_ENTITY_ID assert calls[-1].data["direction"] == DIRECTION_FORWARD @pytest.mark.parametrize( ("count", "extra_config"), [(1, OPTIMISTIC_PRESET_MODE_CONFIG2)] ) @pytest.mark.parametrize( ("style", "fan_config"), [ ( ConfigurationStyle.LEGACY, { "value_template": "{{ 'on' }}", }, ), ( ConfigurationStyle.MODERN, { "state": "{{ 'on' }}", }, ), ], ) @pytest.mark.usefixtures("setup_test_fan_with_extra_config") async def test_preset_modes(hass: HomeAssistant, calls: list[ServiceCall]) -> None: """Test preset_modes.""" expected_calls = 0 valid_modes = OPTIMISTIC_PRESET_MODE_CONFIG2["preset_modes"] for mode in ("auto", "low", "medium", "high", "invalid", "smart"): if mode not in valid_modes: with pytest.raises(NotValidPresetModeError): await common.async_set_preset_mode(hass, TEST_ENTITY_ID, mode) else: await common.async_set_preset_mode(hass, TEST_ENTITY_ID, mode) expected_calls += 1 assert len(calls) == expected_calls assert calls[-1].data["action"] == "set_preset_mode" assert calls[-1].data["caller"] == TEST_ENTITY_ID assert calls[-1].data["preset_mode"] == mode @pytest.mark.parametrize(("count", "extra_config"), [(1, OPTIMISTIC_PERCENTAGE_CONFIG)]) @pytest.mark.parametrize( ("style", "fan_config"), [ ( ConfigurationStyle.LEGACY, { "value_template": "{{ 'on' }}", }, ), ( ConfigurationStyle.MODERN, { "state": "{{ 'on' }}", }, ), ], ) @pytest.mark.usefixtures("setup_test_fan_with_extra_config") async def test_set_percentage(hass: HomeAssistant, calls: list[ServiceCall]) -> None: """Test set valid speed percentage.""" expected_calls = 0 await common.async_turn_on(hass, TEST_ENTITY_ID) expected_calls += 1 for state, value in ( (STATE_ON, 100), (STATE_ON, 66), (STATE_ON, 0), ): await common.async_set_percentage(hass, TEST_ENTITY_ID, value) _verify(hass, state, value, None, None, None) expected_calls += 1 assert len(calls) == expected_calls assert calls[-1].data["action"] == "set_percentage" assert calls[-1].data["caller"] == TEST_ENTITY_ID assert calls[-1].data["percentage"] == value await common.async_turn_on(hass, TEST_ENTITY_ID, percentage=50) _verify(hass, STATE_ON, 50, None, None, None) @pytest.mark.parametrize( ("count", "extra_config"), [(1, {"speed_count": 3, **OPTIMISTIC_PERCENTAGE_CONFIG})] ) @pytest.mark.parametrize( ("style", "fan_config"), [ ( ConfigurationStyle.LEGACY, { "value_template": "{{ 'on' }}", }, ), ( ConfigurationStyle.MODERN, { "state": "{{ 'on' }}", }, ), ], ) @pytest.mark.usefixtures("setup_test_fan_with_extra_config") async def test_increase_decrease_speed( hass: HomeAssistant, calls: list[ServiceCall] ) -> None: """Test set valid increase and decrease speed.""" await common.async_turn_on(hass, TEST_ENTITY_ID) for func, extra, state, value in ( (common.async_set_percentage, 100, STATE_ON, 100), (common.async_decrease_speed, None, STATE_ON, 66), (common.async_decrease_speed, None, STATE_ON, 33), (common.async_decrease_speed, None, STATE_ON, 0), (common.async_increase_speed, None, STATE_ON, 33), ): await func(hass, TEST_ENTITY_ID, extra) _verify(hass, state, value, None, None, None) @pytest.mark.parametrize( ("count", "fan_config"), [ ( 1, { **OPTIMISTIC_ON_OFF_ACTIONS, "preset_modes": ["auto"], **PRESET_MODE_ACTION, **PERCENTAGE_ACTION, **OSCILLATE_ACTION, **DIRECTION_ACTION, }, ) ], ) @pytest.mark.parametrize( "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN], ) @pytest.mark.usefixtures("setup_named_fan") async def test_optimistic_state(hass: HomeAssistant, calls: list[ServiceCall]) -> None: """Test a fan without a value_template.""" await common.async_turn_on(hass, TEST_ENTITY_ID) _verify(hass, STATE_ON) assert len(calls) == 1 assert calls[-1].data["action"] == "turn_on" assert calls[-1].data["caller"] == TEST_ENTITY_ID await common.async_turn_off(hass, TEST_ENTITY_ID) _verify(hass, STATE_OFF) assert len(calls) == 2 assert calls[-1].data["action"] == "turn_off" assert calls[-1].data["caller"] == TEST_ENTITY_ID percent = 100 await common.async_set_percentage(hass, TEST_ENTITY_ID, percent) _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_ENTITY_ID await common.async_turn_off(hass, TEST_ENTITY_ID) _verify(hass, STATE_OFF, percent) assert len(calls) == 4 assert calls[-1].data["action"] == "turn_off" assert calls[-1].data["caller"] == TEST_ENTITY_ID preset = "auto" await common.async_set_preset_mode(hass, TEST_ENTITY_ID, 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_ENTITY_ID await common.async_turn_off(hass, TEST_ENTITY_ID) _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_ENTITY_ID await common.async_set_direction(hass, TEST_ENTITY_ID, 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_ENTITY_ID await common.async_oscillate(hass, TEST_ENTITY_ID, True) _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_ENTITY_ID @pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] ) @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, ), ], ) @pytest.mark.usefixtures("setup_optimistic_fan_attribute") async def test_optimistic_attributes( hass: HomeAssistant, attribute: str, action: str, verify_attr: str, coro, value: Any, calls: list[ServiceCall], ) -> None: """Test setting percentage with optimistic template.""" await coro(hass, TEST_ENTITY_ID, 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_ENTITY_ID @pytest.mark.parametrize(("count", "extra_config"), [(1, OPTIMISTIC_PERCENTAGE_CONFIG)]) @pytest.mark.parametrize( ("style", "fan_config"), [ ( ConfigurationStyle.LEGACY, { "value_template": "{{ 'on' }}", }, ), ( ConfigurationStyle.MODERN, { "state": "{{ 'on' }}", }, ), ], ) @pytest.mark.usefixtures("setup_test_fan_with_extra_config") async def test_increase_decrease_speed_default_speed_count( hass: HomeAssistant, calls: list[ServiceCall] ) -> None: """Test set valid increase and decrease speed.""" await common.async_turn_on(hass, TEST_ENTITY_ID) for func, extra, state, value in ( (common.async_set_percentage, 100, STATE_ON, 100), (common.async_decrease_speed, None, STATE_ON, 99), (common.async_decrease_speed, None, STATE_ON, 98), (common.async_decrease_speed, 31, STATE_ON, 67), (common.async_decrease_speed, None, STATE_ON, 66), ): await func(hass, TEST_ENTITY_ID, extra) _verify(hass, state, value, None, None, None) @pytest.mark.parametrize( ("count", "extra_config"), [(1, {**OPTIMISTIC_ON_OFF_ACTIONS, **OSCILLATE_ACTION})] ) @pytest.mark.parametrize( ("style", "fan_config"), [ ( ConfigurationStyle.LEGACY, { "value_template": "{{ 'on' }}", }, ), ( ConfigurationStyle.MODERN, { "state": "{{ 'on' }}", }, ), ], ) @pytest.mark.usefixtures("setup_test_fan_with_extra_config") async def test_set_invalid_osc_from_initial_state( hass: HomeAssistant, calls: list[ServiceCall] ) -> None: """Test set invalid oscillating when fan is in initial state.""" await common.async_turn_on(hass, TEST_ENTITY_ID) with pytest.raises(vol.Invalid): await common.async_oscillate(hass, TEST_ENTITY_ID, "invalid") _verify(hass, STATE_ON, None, None, None, None) @pytest.mark.parametrize( ("count", "extra_config"), [(1, {**OPTIMISTIC_ON_OFF_ACTIONS, **OSCILLATE_ACTION})] ) @pytest.mark.parametrize( ("style", "fan_config"), [ ( ConfigurationStyle.LEGACY, { "value_template": "{{ 'on' }}", }, ), ( ConfigurationStyle.MODERN, { "state": "{{ 'on' }}", }, ), ], ) @pytest.mark.usefixtures("setup_test_fan_with_extra_config") async def test_set_invalid_osc(hass: HomeAssistant, calls: list[ServiceCall]) -> None: """Test set invalid oscillating when fan has valid osc.""" await common.async_turn_on(hass, TEST_ENTITY_ID) await common.async_oscillate(hass, TEST_ENTITY_ID, True) _verify(hass, STATE_ON, None, True, None, None) await common.async_oscillate(hass, TEST_ENTITY_ID, False) _verify(hass, STATE_ON, None, False, None, None) with pytest.raises(vol.Invalid): await common.async_oscillate(hass, TEST_ENTITY_ID, None) _verify(hass, STATE_ON, None, False, None, None) @pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( ("fan_config", "style"), [ ( { "test_template_cover_01": UNIQUE_ID_CONFIG, "test_template_cover_02": UNIQUE_ID_CONFIG, }, ConfigurationStyle.LEGACY, ), ( [ { "name": "test_template_cover_01", **UNIQUE_ID_CONFIG, }, { "name": "test_template_cover_02", **UNIQUE_ID_CONFIG, }, ], ConfigurationStyle.MODERN, ), ], ) @pytest.mark.usefixtures("setup_fan") async def test_unique_id(hass: HomeAssistant) -> None: """Test unique_id option only creates one fan per id.""" assert len(hass.states.async_all()) == 1 @pytest.mark.parametrize( ("count", "extra_config"), [(1, {**OPTIMISTIC_ON_OFF_ACTIONS, **OPTIMISTIC_PERCENTAGE_CONFIG})], ) @pytest.mark.parametrize( "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN], ) @pytest.mark.parametrize( ("fan_config", "percentage_step"), [({"speed_count": 0}, 1), ({"speed_count": 100}, 1), ({"speed_count": 3}, 100 / 3)], ) @pytest.mark.usefixtures("setup_test_fan_with_extra_config") async def test_speed_percentage_step(hass: HomeAssistant, percentage_step) -> None: """Test a fan that implements percentage.""" assert len(hass.states.async_all()) == 1 state = hass.states.get(TEST_ENTITY_ID) attributes = state.attributes assert attributes["percentage_step"] == percentage_step assert attributes.get("supported_features") & FanEntityFeature.SET_SPEED @pytest.mark.parametrize( ("count", "fan_config"), [(1, {**OPTIMISTIC_ON_OFF_ACTIONS, **OPTIMISTIC_PRESET_MODE_CONFIG2})], ) @pytest.mark.parametrize( "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN], ) @pytest.mark.usefixtures("setup_named_fan") async def test_preset_mode_supported_features(hass: HomeAssistant) -> None: """Test a fan that implements preset_mode.""" assert len(hass.states.async_all()) == 1 state = hass.states.get(TEST_ENTITY_ID) attributes = state.attributes 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": [], }, ), ( ConfigurationStyle.MODERN, { "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_ENTITY_ID) assert state.attributes["supported_features"] == ( FanEntityFeature.TURN_OFF | FanEntityFeature.TURN_ON | supported_features ) async def test_nested_unique_id( hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: """Test a template unique_id propagates to switch unique_ids.""" with assert_setup_component(1, template.DOMAIN): assert await async_setup_component( hass, template.DOMAIN, { "template": { "unique_id": "x", "fan": [ { **OPTIMISTIC_ON_OFF_ACTIONS, "name": "test_a", "unique_id": "a", "state": "{{ true }}", }, { **OPTIMISTIC_ON_OFF_ACTIONS, "name": "test_b", "unique_id": "b", "state": "{{ true }}", }, ], }, }, ) await hass.async_block_till_done() await hass.async_start() await hass.async_block_till_done() assert len(hass.states.async_all("fan")) == 2 entry = entity_registry.async_get("fan.test_a") assert entry assert entry.unique_id == "x-a" entry = entity_registry.async_get("fan.test_b") assert entry assert entry.unique_id == "x-b"