From 16de5edcbf794cb937431ffaf747cf441dc3adb3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Diego=20Elio=20Petten=C3=B2?= Date: Wed, 20 Apr 2022 07:59:57 +0100 Subject: [PATCH] Don't attempt to set invalid fan percentage and preset_mode (try 3) (#70294) Co-authored-by: J. Nick Koston --- .../components/fan/reproduce_state.py | 76 ++-- tests/components/fan/test_reproduce_state.py | 394 ++++++++++++++++++ 2 files changed, 438 insertions(+), 32 deletions(-) diff --git a/homeassistant/components/fan/reproduce_state.py b/homeassistant/components/fan/reproduce_state.py index be018aa4b54..a12b23cb16d 100644 --- a/homeassistant/components/fan/reproduce_state.py +++ b/homeassistant/components/fan/reproduce_state.py @@ -2,7 +2,7 @@ from __future__ import annotations import asyncio -from collections.abc import Iterable, Mapping +from collections.abc import Iterable import logging from typing import Any @@ -30,13 +30,18 @@ from . import ( _LOGGER = logging.getLogger(__name__) VALID_STATES = {STATE_ON, STATE_OFF} -ATTRIBUTES = { # attribute: service - ATTR_DIRECTION: SERVICE_SET_DIRECTION, - ATTR_OSCILLATING: SERVICE_OSCILLATE, + +# These are used as parameters to fan.turn_on service. +SPEED_AND_MODE_ATTRIBUTES = { ATTR_PERCENTAGE: SERVICE_SET_PERCENTAGE, ATTR_PRESET_MODE: SERVICE_SET_PRESET_MODE, } +SIMPLE_ATTRIBUTES = { # attribute: service + ATTR_DIRECTION: SERVICE_SET_DIRECTION, + ATTR_OSCILLATING: SERVICE_OSCILLATE, +} + async def _async_reproduce_state( hass: HomeAssistant, @@ -56,37 +61,49 @@ async def _async_reproduce_state( ) return - # Return if we are already at the right state. - if cur_state.state == state.state and all( - check_attr_equal(cur_state.attributes, state.attributes, attr) - for attr in ATTRIBUTES - ): - return - - service_data = {ATTR_ENTITY_ID: state.entity_id} - service_calls = {} # service: service_data + service_calls: dict[str, dict[str, Any]] = {} if state.state == STATE_ON: # The fan should be on if cur_state.state != STATE_ON: - # Turn on the fan at first - service_calls[SERVICE_TURN_ON] = service_data + # Turn on the fan with all the speed and modes attributes. + # The `turn_on` method will figure out in which mode to + # turn the fan on. + service_calls[SERVICE_TURN_ON] = { + attr: state.attributes.get(attr) + for attr in SPEED_AND_MODE_ATTRIBUTES + if state.attributes.get(attr) is not None + } + else: + # If the fan is already on, we need to set speed or mode + # based on the state. + # + # Speed and preset mode are mutually exclusive, so one of + # them is always going to be stored as None. If we were to + # try to set it, it will raise an error. So instead we + # only update the one that is non-None. + for attr, service in SPEED_AND_MODE_ATTRIBUTES.items(): + value = state.attributes.get(attr) + if value is not None and value != cur_state.attributes.get(attr): + service_calls[service] = {attr: value} - for attr, service in ATTRIBUTES.items(): - # Call services to adjust the attributes - if attr in state.attributes and not check_attr_equal( - state.attributes, cur_state.attributes, attr - ): - data = service_data.copy() - data[attr] = state.attributes[attr] - service_calls[service] = data - - elif state.state == STATE_OFF: - service_calls[SERVICE_TURN_OFF] = service_data + # The simple attributes are copied directly. They can only be + # None if the fan does not support the feature in the first + # place, so the equality check ensures we don't call the + # services with invalid parameters. + for attr, service in SIMPLE_ATTRIBUTES.items(): + if (value := state.attributes.get(attr)) != cur_state.attributes.get(attr): + service_calls[service] = {attr: value} + elif state.state == STATE_OFF and cur_state.state != state.state: + service_calls[SERVICE_TURN_OFF] = {} for service, data in service_calls.items(): await hass.services.async_call( - DOMAIN, service, data, context=context, blocking=True + DOMAIN, + service, + {ATTR_ENTITY_ID: state.entity_id, **data}, + context=context, + blocking=True, ) @@ -106,8 +123,3 @@ async def async_reproduce_states( for state in states ) ) - - -def check_attr_equal(attr1: Mapping, attr2: Mapping, attr_str: str) -> bool: - """Return true if the given attributes are equal.""" - return attr1.get(attr_str) == attr2.get(attr_str) diff --git a/tests/components/fan/test_reproduce_state.py b/tests/components/fan/test_reproduce_state.py index fe1f27ba625..cca56d3e128 100644 --- a/tests/components/fan/test_reproduce_state.py +++ b/tests/components/fan/test_reproduce_state.py @@ -1,4 +1,16 @@ """Test reproduce state for Fan.""" + +import pytest + +from homeassistant.components.fan import ( + ATTR_DIRECTION, + ATTR_OSCILLATING, + ATTR_PERCENTAGE, + ATTR_PRESET_MODE, + ATTR_PRESET_MODES, + DIRECTION_FORWARD, + DIRECTION_REVERSE, +) from homeassistant.core import State from homeassistant.helpers.state import async_reproduce_state @@ -88,3 +100,385 @@ async def test_reproducing_states(hass, caplog): assert len(turn_off_calls) == 1 assert turn_off_calls[0].domain == "fan" assert turn_off_calls[0].data == {"entity_id": "fan.entity_on"} + + +MODERN_FAN_ENTITY = "fan.modern_fan" +MODERN_FAN_OFF_PERCENTAGE10_STATE = { + ATTR_OSCILLATING: True, + ATTR_DIRECTION: DIRECTION_FORWARD, + ATTR_PERCENTAGE: 10, + ATTR_PRESET_MODES: ["Auto", "Eco"], +} +MODERN_FAN_OFF_PERCENTAGE15_STATE = { + ATTR_OSCILLATING: True, + ATTR_DIRECTION: DIRECTION_FORWARD, + ATTR_PERCENTAGE: 15, + ATTR_PRESET_MODES: ["Auto", "Eco"], +} +MODERN_FAN_ON_INVALID_STATE = { + ATTR_PRESET_MODES: ["Auto", "Eco"], +} +MODERN_FAN_OFF_PPRESET_MODE_AUTO_STATE = { + ATTR_OSCILLATING: True, + ATTR_DIRECTION: DIRECTION_FORWARD, + ATTR_PRESET_MODE: "Auto", + ATTR_PRESET_MODES: ["Auto", "Eco"], +} +MODERN_FAN_OFF_PPRESET_MODE_ECO_STATE = { + ATTR_OSCILLATING: True, + ATTR_DIRECTION: DIRECTION_FORWARD, + ATTR_PRESET_MODE: "Eco", + ATTR_PRESET_MODES: ["Auto", "Eco"], +} +MODERN_FAN_ON_PERCENTAGE10_STATE = { + ATTR_OSCILLATING: True, + ATTR_DIRECTION: DIRECTION_FORWARD, + ATTR_PERCENTAGE: 10, + ATTR_PRESET_MODES: ["Auto", "Eco"], +} +MODERN_FAN_ON_PERCENTAGE15_STATE = { + ATTR_OSCILLATING: True, + ATTR_DIRECTION: DIRECTION_FORWARD, + ATTR_PERCENTAGE: 15, + ATTR_PRESET_MODES: ["Auto", "Eco"], +} +MODERN_FAN_ON_PRESET_MODE_AUTO_STATE = { + ATTR_OSCILLATING: True, + ATTR_DIRECTION: DIRECTION_FORWARD, + ATTR_PRESET_MODE: "Auto", + ATTR_PRESET_MODES: ["Auto", "Eco"], +} +MODERN_FAN_ON_PRESET_MODE_ECO_STATE = { + ATTR_OSCILLATING: True, + ATTR_DIRECTION: DIRECTION_FORWARD, + ATTR_PRESET_MODE: "Eco", + ATTR_PRESET_MODES: ["Auto", "Eco"], +} +MODERN_FAN_PRESET_MODE_AUTO_REVERSE_STATE = { + ATTR_OSCILLATING: True, + ATTR_DIRECTION: DIRECTION_REVERSE, + ATTR_PRESET_MODE: "Auto", + ATTR_PRESET_MODES: ["Auto", "Eco"], +} + + +@pytest.mark.parametrize( + "start_state", + [ + MODERN_FAN_OFF_PERCENTAGE10_STATE, + MODERN_FAN_OFF_PERCENTAGE15_STATE, + MODERN_FAN_OFF_PPRESET_MODE_AUTO_STATE, + MODERN_FAN_OFF_PPRESET_MODE_ECO_STATE, + ], +) +async def test_modern_turn_on_invalid(hass, start_state): + """Test modern fan state reproduction, turning on with invalid state.""" + hass.states.async_set(MODERN_FAN_ENTITY, "off", start_state) + + turn_on_calls = async_mock_service(hass, "fan", "turn_on") + turn_off_calls = async_mock_service(hass, "fan", "turn_off") + set_direction_calls = async_mock_service(hass, "fan", "set_direction") + oscillate_calls = async_mock_service(hass, "fan", "oscillate") + set_percentage_mode = async_mock_service(hass, "fan", "set_percentage") + set_preset_mode = async_mock_service(hass, "fan", "set_preset_mode") + + # Turn on with an invalid config (speed, percentage, preset_modes all None) + await hass.helpers.state.async_reproduce_state( + [State(MODERN_FAN_ENTITY, "on", MODERN_FAN_ON_INVALID_STATE)] + ) + + assert len(turn_on_calls) == 1 + assert turn_on_calls[0].domain == "fan" + assert turn_on_calls[0].data == {"entity_id": MODERN_FAN_ENTITY} + + assert len(turn_off_calls) == 0 + assert len(set_direction_calls) == 1 + assert set_direction_calls[0].domain == "fan" + assert set_direction_calls[0].data == { + "entity_id": MODERN_FAN_ENTITY, + ATTR_DIRECTION: None, + } + assert len(oscillate_calls) == 1 + assert oscillate_calls[0].domain == "fan" + assert oscillate_calls[0].data == { + "entity_id": MODERN_FAN_ENTITY, + ATTR_OSCILLATING: None, + } + assert len(set_percentage_mode) == 0 + assert len(set_preset_mode) == 0 + + +@pytest.mark.parametrize( + "start_state", + [ + MODERN_FAN_OFF_PERCENTAGE10_STATE, + MODERN_FAN_OFF_PPRESET_MODE_AUTO_STATE, + MODERN_FAN_OFF_PPRESET_MODE_ECO_STATE, + ], +) +async def test_modern_turn_on_percentage_from_different_speed(hass, start_state): + """Test modern fan state reproduction, turning on with a different percentage of the state.""" + hass.states.async_set(MODERN_FAN_ENTITY, "off", start_state) + + turn_on_calls = async_mock_service(hass, "fan", "turn_on") + turn_off_calls = async_mock_service(hass, "fan", "turn_off") + set_direction_calls = async_mock_service(hass, "fan", "set_direction") + oscillate_calls = async_mock_service(hass, "fan", "oscillate") + set_percentage_mode = async_mock_service(hass, "fan", "set_percentage") + set_preset_mode = async_mock_service(hass, "fan", "set_preset_mode") + + await hass.helpers.state.async_reproduce_state( + [State(MODERN_FAN_ENTITY, "on", MODERN_FAN_ON_PERCENTAGE15_STATE)] + ) + + assert len(turn_on_calls) == 1 + assert turn_on_calls[0].domain == "fan" + assert turn_on_calls[0].data == { + "entity_id": MODERN_FAN_ENTITY, + ATTR_PERCENTAGE: 15, + } + + assert len(turn_off_calls) == 0 + assert len(set_direction_calls) == 0 + assert len(oscillate_calls) == 0 + assert len(set_percentage_mode) == 0 + assert len(set_preset_mode) == 0 + + +async def test_modern_turn_on_percentage_from_same_speed(hass): + """Test modern fan state reproduction, turning on with the same percentage as in the state.""" + hass.states.async_set(MODERN_FAN_ENTITY, "off", MODERN_FAN_OFF_PERCENTAGE15_STATE) + + turn_on_calls = async_mock_service(hass, "fan", "turn_on") + turn_off_calls = async_mock_service(hass, "fan", "turn_off") + set_direction_calls = async_mock_service(hass, "fan", "set_direction") + oscillate_calls = async_mock_service(hass, "fan", "oscillate") + set_percentage_mode = async_mock_service(hass, "fan", "set_percentage") + set_preset_mode = async_mock_service(hass, "fan", "set_preset_mode") + + await hass.helpers.state.async_reproduce_state( + [State(MODERN_FAN_ENTITY, "on", MODERN_FAN_ON_PERCENTAGE15_STATE)] + ) + + assert len(turn_on_calls) == 1 + assert turn_on_calls[0].domain == "fan" + assert turn_on_calls[0].data == { + "entity_id": MODERN_FAN_ENTITY, + ATTR_PERCENTAGE: 15, + } + + assert len(turn_off_calls) == 0 + assert len(set_direction_calls) == 0 + assert len(oscillate_calls) == 0 + assert len(set_percentage_mode) == 0 + assert len(set_preset_mode) == 0 + + +@pytest.mark.parametrize( + "start_state", + [ + MODERN_FAN_OFF_PERCENTAGE10_STATE, + MODERN_FAN_OFF_PERCENTAGE15_STATE, + MODERN_FAN_OFF_PPRESET_MODE_ECO_STATE, + ], +) +async def test_modern_turn_on_preset_mode_from_different_speed(hass, start_state): + """Test modern fan state reproduction, turning on with a different preset mode from the state.""" + hass.states.async_set(MODERN_FAN_ENTITY, "off", start_state) + + turn_on_calls = async_mock_service(hass, "fan", "turn_on") + turn_off_calls = async_mock_service(hass, "fan", "turn_off") + set_direction_calls = async_mock_service(hass, "fan", "set_direction") + oscillate_calls = async_mock_service(hass, "fan", "oscillate") + set_percentage_mode = async_mock_service(hass, "fan", "set_percentage") + set_preset_mode = async_mock_service(hass, "fan", "set_preset_mode") + + await hass.helpers.state.async_reproduce_state( + [State(MODERN_FAN_ENTITY, "on", MODERN_FAN_ON_PRESET_MODE_AUTO_STATE)] + ) + + assert len(turn_on_calls) == 1 + assert turn_on_calls[0].domain == "fan" + assert turn_on_calls[0].data == { + "entity_id": MODERN_FAN_ENTITY, + ATTR_PRESET_MODE: "Auto", + } + + assert len(turn_off_calls) == 0 + assert len(set_direction_calls) == 0 + assert len(oscillate_calls) == 0 + assert len(set_percentage_mode) == 0 + assert len(set_preset_mode) == 0 + + +async def test_modern_turn_on_preset_mode_from_same_speed(hass): + """Test modern fan state reproduction, turning on with the same preset mode as in the state.""" + hass.states.async_set( + MODERN_FAN_ENTITY, "off", MODERN_FAN_OFF_PPRESET_MODE_AUTO_STATE + ) + + turn_on_calls = async_mock_service(hass, "fan", "turn_on") + turn_off_calls = async_mock_service(hass, "fan", "turn_off") + set_direction_calls = async_mock_service(hass, "fan", "set_direction") + oscillate_calls = async_mock_service(hass, "fan", "oscillate") + set_percentage_mode = async_mock_service(hass, "fan", "set_percentage") + set_preset_mode = async_mock_service(hass, "fan", "set_preset_mode") + + await hass.helpers.state.async_reproduce_state( + [State(MODERN_FAN_ENTITY, "on", MODERN_FAN_ON_PRESET_MODE_AUTO_STATE)] + ) + + assert len(turn_on_calls) == 1 + assert turn_on_calls[0].domain == "fan" + assert turn_on_calls[0].data == { + "entity_id": MODERN_FAN_ENTITY, + ATTR_PRESET_MODE: "Auto", + } + + assert len(turn_off_calls) == 0 + assert len(set_direction_calls) == 0 + assert len(oscillate_calls) == 0 + assert len(set_percentage_mode) == 0 + assert len(set_preset_mode) == 0 + + +@pytest.mark.parametrize( + "start_state", + [ + MODERN_FAN_OFF_PERCENTAGE10_STATE, + MODERN_FAN_OFF_PERCENTAGE15_STATE, + MODERN_FAN_OFF_PPRESET_MODE_ECO_STATE, + ], +) +async def test_modern_turn_on_preset_mode_reverse(hass, start_state): + """Test modern fan state reproduction, turning on with preset mode "Auto" and reverse direction.""" + hass.states.async_set(MODERN_FAN_ENTITY, "off", start_state) + + turn_on_calls = async_mock_service(hass, "fan", "turn_on") + turn_off_calls = async_mock_service(hass, "fan", "turn_off") + set_direction_calls = async_mock_service(hass, "fan", "set_direction") + oscillate_calls = async_mock_service(hass, "fan", "oscillate") + set_percentage_mode = async_mock_service(hass, "fan", "set_percentage") + set_preset_mode = async_mock_service(hass, "fan", "set_preset_mode") + + await hass.helpers.state.async_reproduce_state( + [State(MODERN_FAN_ENTITY, "on", MODERN_FAN_PRESET_MODE_AUTO_REVERSE_STATE)] + ) + + assert len(turn_on_calls) == 1 + assert turn_on_calls[0].domain == "fan" + assert turn_on_calls[0].data == { + "entity_id": MODERN_FAN_ENTITY, + ATTR_PRESET_MODE: "Auto", + } + assert len(set_direction_calls) == 1 + assert set_direction_calls[0].domain == "fan" + assert set_direction_calls[0].data == { + "entity_id": MODERN_FAN_ENTITY, + ATTR_DIRECTION: DIRECTION_REVERSE, + } + + assert len(turn_off_calls) == 0 + assert len(oscillate_calls) == 0 + assert len(set_percentage_mode) == 0 + assert len(set_preset_mode) == 0 + + +@pytest.mark.parametrize( + "start_state", + [ + MODERN_FAN_ON_PERCENTAGE10_STATE, + MODERN_FAN_ON_PERCENTAGE15_STATE, + MODERN_FAN_ON_PRESET_MODE_ECO_STATE, + ], +) +async def test_modern_to_preset(hass, start_state): + """Test modern fan state reproduction, switching to preset mode "Auto".""" + hass.states.async_set(MODERN_FAN_ENTITY, "on", start_state) + + turn_on_calls = async_mock_service(hass, "fan", "turn_on") + turn_off_calls = async_mock_service(hass, "fan", "turn_off") + set_direction_calls = async_mock_service(hass, "fan", "set_direction") + oscillate_calls = async_mock_service(hass, "fan", "oscillate") + set_percentage_mode = async_mock_service(hass, "fan", "set_percentage") + set_preset_mode = async_mock_service(hass, "fan", "set_preset_mode") + + await hass.helpers.state.async_reproduce_state( + [State(MODERN_FAN_ENTITY, "on", MODERN_FAN_ON_PRESET_MODE_AUTO_STATE)] + ) + + assert len(turn_on_calls) == 0 + assert len(turn_off_calls) == 0 + assert len(set_direction_calls) == 0 + assert len(oscillate_calls) == 0 + assert len(set_percentage_mode) == 0 + assert len(set_preset_mode) == 1 + assert set_preset_mode[0].domain == "fan" + assert set_preset_mode[0].data == { + "entity_id": MODERN_FAN_ENTITY, + ATTR_PRESET_MODE: "Auto", + } + + +@pytest.mark.parametrize( + "start_state", + [ + MODERN_FAN_ON_PERCENTAGE10_STATE, + MODERN_FAN_ON_PRESET_MODE_AUTO_STATE, + MODERN_FAN_ON_PRESET_MODE_ECO_STATE, + ], +) +async def test_modern_to_percentage(hass, start_state): + """Test modern fan state reproduction, switching to 15% speed.""" + hass.states.async_set(MODERN_FAN_ENTITY, "on", start_state) + + turn_on_calls = async_mock_service(hass, "fan", "turn_on") + turn_off_calls = async_mock_service(hass, "fan", "turn_off") + set_direction_calls = async_mock_service(hass, "fan", "set_direction") + oscillate_calls = async_mock_service(hass, "fan", "oscillate") + set_percentage_mode = async_mock_service(hass, "fan", "set_percentage") + set_preset_mode = async_mock_service(hass, "fan", "set_preset_mode") + + await hass.helpers.state.async_reproduce_state( + [State(MODERN_FAN_ENTITY, "on", MODERN_FAN_ON_PERCENTAGE15_STATE)] + ) + + assert len(turn_on_calls) == 0 + assert len(turn_off_calls) == 0 + assert len(set_direction_calls) == 0 + assert len(oscillate_calls) == 0 + assert len(set_percentage_mode) == 1 + assert set_percentage_mode[0].domain == "fan" + assert set_percentage_mode[0].data == { + "entity_id": MODERN_FAN_ENTITY, + ATTR_PERCENTAGE: 15, + } + assert len(set_preset_mode) == 0 + + +async def test_modern_direction(hass): + """Test modern fan state reproduction, switching only direction state.""" + hass.states.async_set(MODERN_FAN_ENTITY, "on", MODERN_FAN_ON_PRESET_MODE_AUTO_STATE) + + turn_on_calls = async_mock_service(hass, "fan", "turn_on") + turn_off_calls = async_mock_service(hass, "fan", "turn_off") + set_direction_calls = async_mock_service(hass, "fan", "set_direction") + oscillate_calls = async_mock_service(hass, "fan", "oscillate") + set_percentage_mode = async_mock_service(hass, "fan", "set_percentage") + set_preset_mode = async_mock_service(hass, "fan", "set_preset_mode") + + await hass.helpers.state.async_reproduce_state( + [State(MODERN_FAN_ENTITY, "on", MODERN_FAN_PRESET_MODE_AUTO_REVERSE_STATE)] + ) + + assert len(turn_on_calls) == 0 + assert len(turn_off_calls) == 0 + assert len(set_direction_calls) == 1 + assert set_direction_calls[0].domain == "fan" + assert set_direction_calls[0].data == { + "entity_id": MODERN_FAN_ENTITY, + ATTR_DIRECTION: DIRECTION_REVERSE, + } + assert len(oscillate_calls) == 0 + assert len(set_percentage_mode) == 0 + assert len(set_preset_mode) == 0