From 3eaa1c30af36320ec54e4cb96b23b5a654919575 Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Thu, 18 Aug 2022 04:15:48 +0200 Subject: [PATCH] Restore fixed step fan speeds for google assistant (#76871) --- .../components/google_assistant/const.py | 18 +++ .../components/google_assistant/trait.py | 58 ++++++++- .../components/google_assistant/test_trait.py | 117 +++++++++++++++++- 3 files changed, 191 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/google_assistant/const.py b/homeassistant/components/google_assistant/const.py index dbcf60ac098..20c4ab60e88 100644 --- a/homeassistant/components/google_assistant/const.py +++ b/homeassistant/components/google_assistant/const.py @@ -186,3 +186,21 @@ SOURCE_CLOUD = "cloud" SOURCE_LOCAL = "local" NOT_EXPOSE_LOCAL = {TYPE_ALARM, TYPE_LOCK} + +FAN_SPEEDS = { + "5/5": ["High", "Max", "Fast", "5"], + "4/5": ["Medium High", "4"], + "3/5": ["Medium", "3"], + "2/5": ["Medium Low", "2"], + "1/5": ["Low", "Min", "Slow", "1"], + "4/4": ["High", "Max", "Fast", "4"], + "3/4": ["Medium High", "3"], + "2/4": ["Medium Low", "2"], + "1/4": ["Low", "Min", "Slow", "1"], + "3/3": ["High", "Max", "Fast", "3"], + "2/3": ["Medium", "2"], + "1/3": ["Low", "Min", "Slow", "1"], + "2/2": ["High", "Max", "Fast", "2"], + "1/2": ["Low", "Min", "Slow", "1"], + "1/1": ["Normal", "1"], +} diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py index edc8ed124b3..defc5b0cc89 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -2,6 +2,7 @@ from __future__ import annotations import logging +from typing import Any from homeassistant.components import ( alarm_control_panel, @@ -68,6 +69,10 @@ from homeassistant.const import ( from homeassistant.core import DOMAIN as HA_DOMAIN from homeassistant.helpers.network import get_url from homeassistant.util import color as color_util, dt, temperature as temp_util +from homeassistant.util.percentage import ( + ordered_list_item_to_percentage, + percentage_to_ordered_list_item, +) from .const import ( CHALLENGE_ACK_NEEDED, @@ -82,6 +87,7 @@ from .const import ( ERR_NOT_SUPPORTED, ERR_UNSUPPORTED_INPUT, ERR_VALUE_OUT_OF_RANGE, + FAN_SPEEDS, ) from .error import ChallengeNeeded, SmartHomeError @@ -157,6 +163,8 @@ COMMAND_CHARGE = f"{PREFIX_COMMANDS}Charge" TRAITS = [] +FAN_SPEED_MAX_SPEED_COUNT = 5 + def register_trait(trait): """Decorate a function to register a trait.""" @@ -1359,6 +1367,20 @@ class ArmDisArmTrait(_Trait): ) +def _get_fan_speed(speed_name: str) -> dict[str, Any]: + """Return a fan speed synonyms for a speed name.""" + speed_synonyms = FAN_SPEEDS.get(speed_name, [f"{speed_name}"]) + return { + "speed_name": speed_name, + "speed_values": [ + { + "speed_synonym": speed_synonyms, + "lang": "en", + } + ], + } + + @register_trait class FanSpeedTrait(_Trait): """Trait to control speed of Fan. @@ -1369,6 +1391,18 @@ class FanSpeedTrait(_Trait): name = TRAIT_FANSPEED commands = [COMMAND_FANSPEED, COMMAND_REVERSE] + def __init__(self, hass, state, config): + """Initialize a trait for a state.""" + super().__init__(hass, state, config) + if state.domain == fan.DOMAIN: + speed_count = min( + FAN_SPEED_MAX_SPEED_COUNT, + round(100 / self.state.attributes.get(fan.ATTR_PERCENTAGE_STEP) or 1.0), + ) + self._ordered_speed = [ + f"{speed}/{speed_count}" for speed in range(1, speed_count + 1) + ] + @staticmethod def supported(domain, features, device_class, _): """Test if state is supported.""" @@ -1397,6 +1431,18 @@ class FanSpeedTrait(_Trait): } ) + if self._ordered_speed: + result.update( + { + "availableFanSpeeds": { + "speeds": [ + _get_fan_speed(speed) for speed in self._ordered_speed + ], + "ordered": True, + }, + } + ) + elif domain == climate.DOMAIN: modes = self.state.attributes.get(climate.ATTR_FAN_MODES) or [] for mode in modes: @@ -1428,6 +1474,9 @@ class FanSpeedTrait(_Trait): if domain == fan.DOMAIN: percent = attrs.get(fan.ATTR_PERCENTAGE) or 0 response["currentFanSpeedPercent"] = percent + response["currentFanSpeedSetting"] = percentage_to_ordered_list_item( + self._ordered_speed, percent + ) return response @@ -1447,12 +1496,19 @@ class FanSpeedTrait(_Trait): ) if domain == fan.DOMAIN: + if fan_speed := params.get("fanSpeed"): + fan_speed_percent = ordered_list_item_to_percentage( + self._ordered_speed, fan_speed + ) + else: + fan_speed_percent = params.get("fanSpeedPercent") + await self.hass.services.async_call( fan.DOMAIN, fan.SERVICE_SET_PERCENTAGE, { ATTR_ENTITY_ID: self.state.entity_id, - fan.ATTR_PERCENTAGE: params["fanSpeedPercent"], + fan.ATTR_PERCENTAGE: fan_speed_percent, }, blocking=not self.config.should_report_state, context=data.context, diff --git a/tests/components/google_assistant/test_trait.py b/tests/components/google_assistant/test_trait.py index 0012826074b..a3024c184d6 100644 --- a/tests/components/google_assistant/test_trait.py +++ b/tests/components/google_assistant/test_trait.py @@ -1,6 +1,6 @@ """Tests for the Google Assistant traits.""" from datetime import datetime, timedelta -from unittest.mock import patch +from unittest.mock import ANY, patch import pytest @@ -1601,10 +1601,12 @@ async def test_fan_speed(hass): assert trt.sync_attributes() == { "reversible": False, "supportsFanSpeedPercent": True, + "availableFanSpeeds": ANY, } assert trt.query_attributes() == { "currentFanSpeedPercent": 33, + "currentFanSpeedSetting": ANY, } assert trt.can_execute(trait.COMMAND_FANSPEED, params={"fanSpeedPercent": 10}) @@ -1616,6 +1618,117 @@ async def test_fan_speed(hass): assert calls[0].data == {"entity_id": "fan.living_room_fan", "percentage": 10} +@pytest.mark.parametrize( + "percentage,percentage_step, speed, speeds, percentage_result", + [ + ( + 33, + 1.0, + "2/5", + [ + ["Low", "Min", "Slow", "1"], + ["Medium Low", "2"], + ["Medium", "3"], + ["Medium High", "4"], + ["High", "Max", "Fast", "5"], + ], + 40, + ), + ( + 40, + 1.0, + "2/5", + [ + ["Low", "Min", "Slow", "1"], + ["Medium Low", "2"], + ["Medium", "3"], + ["Medium High", "4"], + ["High", "Max", "Fast", "5"], + ], + 40, + ), + ( + 33, + 100 / 3, + "1/3", + [ + ["Low", "Min", "Slow", "1"], + ["Medium", "2"], + ["High", "Max", "Fast", "3"], + ], + 33, + ), + ( + 20, + 100 / 4, + "1/4", + [ + ["Low", "Min", "Slow", "1"], + ["Medium Low", "2"], + ["Medium High", "3"], + ["High", "Max", "Fast", "4"], + ], + 25, + ), + ], +) +async def test_fan_speed_ordered( + hass, + percentage: int, + percentage_step: float, + speed: str, + speeds: list[list[str]], + percentage_result: int, +): + """Test FanSpeed trait speed control support for fan domain.""" + assert helpers.get_google_type(fan.DOMAIN, None) is not None + assert trait.FanSpeedTrait.supported(fan.DOMAIN, fan.SUPPORT_SET_SPEED, None, None) + + trt = trait.FanSpeedTrait( + hass, + State( + "fan.living_room_fan", + STATE_ON, + attributes={ + "percentage": percentage, + "percentage_step": percentage_step, + }, + ), + BASIC_CONFIG, + ) + + assert trt.sync_attributes() == { + "reversible": False, + "supportsFanSpeedPercent": True, + "availableFanSpeeds": { + "ordered": True, + "speeds": [ + { + "speed_name": f"{idx+1}/{len(speeds)}", + "speed_values": [{"lang": "en", "speed_synonym": x}], + } + for idx, x in enumerate(speeds) + ], + }, + } + + assert trt.query_attributes() == { + "currentFanSpeedPercent": percentage, + "currentFanSpeedSetting": speed, + } + + assert trt.can_execute(trait.COMMAND_FANSPEED, params={"fanSpeed": speed}) + + calls = async_mock_service(hass, fan.DOMAIN, fan.SERVICE_SET_PERCENTAGE) + await trt.execute(trait.COMMAND_FANSPEED, BASIC_DATA, {"fanSpeed": speed}, {}) + + assert len(calls) == 1 + assert calls[0].data == { + "entity_id": "fan.living_room_fan", + "percentage": percentage_result, + } + + @pytest.mark.parametrize( "direction_state,direction_call", [ @@ -1647,10 +1760,12 @@ async def test_fan_reverse(hass, direction_state, direction_call): assert trt.sync_attributes() == { "reversible": True, "supportsFanSpeedPercent": True, + "availableFanSpeeds": ANY, } assert trt.query_attributes() == { "currentFanSpeedPercent": 33, + "currentFanSpeedSetting": ANY, } assert trt.can_execute(trait.COMMAND_REVERSE, params={})