diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py index 8286e527159..d7cd55b7f80 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -121,6 +121,7 @@ COMMAND_THERMOSTAT_TEMPERATURE_SET_RANGE = ( COMMAND_THERMOSTAT_SET_MODE = f"{PREFIX_COMMANDS}ThermostatSetMode" COMMAND_LOCKUNLOCK = f"{PREFIX_COMMANDS}LockUnlock" COMMAND_FANSPEED = f"{PREFIX_COMMANDS}SetFanSpeed" +COMMAND_FANSPEEDRELATIVE = f"{PREFIX_COMMANDS}SetFanSpeedRelative" COMMAND_MODES = f"{PREFIX_COMMANDS}SetModes" COMMAND_INPUT = f"{PREFIX_COMMANDS}SetInput" COMMAND_NEXT_INPUT = f"{PREFIX_COMMANDS}NextInput" @@ -1276,10 +1277,9 @@ class FanSpeedTrait(_Trait): reversible = False if domain == fan.DOMAIN: + # The use of legacy speeds is deprecated in the schema, support will be removed after a quarter (2021.7) modes = self.state.attributes.get(fan.ATTR_SPEED_LIST, []) for mode in modes: - if mode not in self.speed_synonyms: - continue speed = { "speed_name": mode, "speed_values": [ @@ -1321,6 +1321,7 @@ class FanSpeedTrait(_Trait): if speed is not None: response["on"] = speed != fan.SPEED_OFF response["currentFanSpeedSetting"] = speed + if percent is not None: response["currentFanSpeedPercent"] = percent return response @@ -1369,6 +1370,7 @@ class ModesTrait(_Trait): commands = [COMMAND_MODES] SYNONYMS = { + "preset mode": ["preset mode", "mode", "preset"], "sound mode": ["sound mode", "effects"], "option": ["option", "setting", "mode", "value"], } @@ -1376,6 +1378,9 @@ class ModesTrait(_Trait): @staticmethod def supported(domain, features, device_class, _): """Test if state is supported.""" + if domain == fan.DOMAIN and features & fan.SUPPORT_PRESET_MODE: + return True + if domain == input_select.DOMAIN: return True @@ -1419,6 +1424,7 @@ class ModesTrait(_Trait): modes = [] for domain, attr, name in ( + (fan.DOMAIN, fan.ATTR_PRESET_MODES, "preset mode"), (media_player.DOMAIN, media_player.ATTR_SOUND_MODE_LIST, "sound mode"), (input_select.DOMAIN, input_select.ATTR_OPTIONS, "option"), (humidifier.DOMAIN, humidifier.ATTR_AVAILABLE_MODES, "mode"), @@ -1445,7 +1451,10 @@ class ModesTrait(_Trait): response = {} mode_settings = {} - if self.state.domain == media_player.DOMAIN: + if self.state.domain == fan.DOMAIN: + if fan.ATTR_PRESET_MODES in attrs: + mode_settings["preset mode"] = attrs.get(fan.ATTR_PRESET_MODE) + elif self.state.domain == media_player.DOMAIN: if media_player.ATTR_SOUND_MODE_LIST in attrs: mode_settings["sound mode"] = attrs.get(media_player.ATTR_SOUND_MODE) elif self.state.domain == input_select.DOMAIN: @@ -1466,8 +1475,22 @@ class ModesTrait(_Trait): """Execute a SetModes command.""" settings = params.get("updateModeSettings") + if self.state.domain == fan.DOMAIN: + preset_mode = settings["preset mode"] + await self.hass.services.async_call( + fan.DOMAIN, + fan.SERVICE_SET_PRESET_MODE, + { + ATTR_ENTITY_ID: self.state.entity_id, + fan.ATTR_PRESET_MODE: preset_mode, + }, + blocking=True, + context=data.context, + ) + return + if self.state.domain == input_select.DOMAIN: - option = params["updateModeSettings"]["option"] + option = settings["option"] await self.hass.services.async_call( input_select.DOMAIN, input_select.SERVICE_SELECT_OPTION, @@ -1508,26 +1531,25 @@ class ModesTrait(_Trait): ) return - if self.state.domain != media_player.DOMAIN: - _LOGGER.info( - "Received an Options command for unrecognised domain %s", - self.state.domain, - ) - return + if self.state.domain == media_player.DOMAIN: + sound_mode = settings.get("sound mode") + if sound_mode: + await self.hass.services.async_call( + media_player.DOMAIN, + media_player.SERVICE_SELECT_SOUND_MODE, + { + ATTR_ENTITY_ID: self.state.entity_id, + media_player.ATTR_SOUND_MODE: sound_mode, + }, + blocking=True, + context=data.context, + ) - sound_mode = settings.get("sound mode") - - if sound_mode: - await self.hass.services.async_call( - media_player.DOMAIN, - media_player.SERVICE_SELECT_SOUND_MODE, - { - ATTR_ENTITY_ID: self.state.entity_id, - media_player.ATTR_SOUND_MODE: sound_mode, - }, - blocking=True, - context=data.context, - ) + _LOGGER.info( + "Received an Options command for unrecognised domain %s", + self.state.domain, + ) + return @register_trait diff --git a/tests/components/google_assistant/__init__.py b/tests/components/google_assistant/__init__.py index 123ca120243..459b5bcadfc 100644 --- a/tests/components/google_assistant/__init__.py +++ b/tests/components/google_assistant/__init__.py @@ -247,14 +247,20 @@ DEMO_DEVICES = [ { "id": "fan.living_room_fan", "name": {"name": "Living Room Fan"}, - "traits": ["action.devices.traits.FanSpeed", "action.devices.traits.OnOff"], + "traits": [ + "action.devices.traits.FanSpeed", + "action.devices.traits.OnOff", + ], "type": "action.devices.types.FAN", "willReportState": False, }, { "id": "fan.ceiling_fan", "name": {"name": "Ceiling Fan"}, - "traits": ["action.devices.traits.FanSpeed", "action.devices.traits.OnOff"], + "traits": [ + "action.devices.traits.FanSpeed", + "action.devices.traits.OnOff", + ], "type": "action.devices.types.FAN", "willReportState": False, }, @@ -275,7 +281,10 @@ DEMO_DEVICES = [ { "id": "fan.preset_only_limited_fan", "name": {"name": "Preset Only Limited Fan"}, - "traits": ["action.devices.traits.OnOff"], + "traits": [ + "action.devices.traits.OnOff", + "action.devices.traits.Modes", + ], "type": "action.devices.types.FAN", "willReportState": False, }, diff --git a/tests/components/google_assistant/test_trait.py b/tests/components/google_assistant/test_trait.py index c3678e7f99a..46d81443a05 100644 --- a/tests/components/google_assistant/test_trait.py +++ b/tests/components/google_assistant/test_trait.py @@ -1429,6 +1429,7 @@ async def test_fan_speed(hass): ], "speed": "low", "percentage": 33, + "percentage_step": 1.0, }, ), BASIC_CONFIG, @@ -1951,6 +1952,97 @@ async def test_sound_modes(hass): } +async def test_preset_modes(hass): + """Test Mode trait for fan preset modes.""" + assert helpers.get_google_type(fan.DOMAIN, None) is not None + assert trait.ModesTrait.supported(fan.DOMAIN, fan.SUPPORT_PRESET_MODE, None, None) + + trt = trait.ModesTrait( + hass, + State( + "fan.living_room", + STATE_ON, + attributes={ + fan.ATTR_PRESET_MODES: ["auto", "whoosh"], + fan.ATTR_PRESET_MODE: "auto", + ATTR_SUPPORTED_FEATURES: fan.SUPPORT_PRESET_MODE, + }, + ), + BASIC_CONFIG, + ) + + attribs = trt.sync_attributes() + assert attribs == { + "availableModes": [ + { + "name": "preset mode", + "name_values": [ + {"name_synonym": ["preset mode", "mode", "preset"], "lang": "en"} + ], + "settings": [ + { + "setting_name": "auto", + "setting_values": [{"setting_synonym": ["auto"], "lang": "en"}], + }, + { + "setting_name": "whoosh", + "setting_values": [ + {"setting_synonym": ["whoosh"], "lang": "en"} + ], + }, + ], + "ordered": False, + } + ] + } + + assert trt.query_attributes() == { + "currentModeSettings": {"preset mode": "auto"}, + "on": True, + } + + assert trt.can_execute( + trait.COMMAND_MODES, + params={"updateModeSettings": {"preset mode": "auto"}}, + ) + + calls = async_mock_service(hass, fan.DOMAIN, fan.SERVICE_SET_PRESET_MODE) + await trt.execute( + trait.COMMAND_MODES, + BASIC_DATA, + {"updateModeSettings": {"preset mode": "auto"}}, + {}, + ) + + assert len(calls) == 1 + assert calls[0].data == { + "entity_id": "fan.living_room", + "preset_mode": "auto", + } + + +async def test_traits_unknown_domains(hass, caplog): + """Test Mode trait for unsupported domain.""" + trt = trait.ModesTrait( + hass, + State( + "switch.living_room", + STATE_ON, + ), + BASIC_CONFIG, + ) + + assert trt.supported("not_supported_domain", False, None, None) is False + await trt.execute( + trait.COMMAND_MODES, + BASIC_DATA, + {"updateModeSettings": {}}, + {}, + ) + assert "Received an Options command for unrecognised domain" in caplog.text + caplog.clear() + + async def test_openclose_cover(hass): """Test OpenClose trait support for cover domain.""" assert helpers.get_google_type(cover.DOMAIN, None) is not None