From e50a6ef8af6192113d9b51fb89f6f42898411613 Mon Sep 17 00:00:00 2001 From: Eric Nagley Date: Thu, 29 Nov 2018 15:14:17 -0500 Subject: [PATCH] Add support for Mode trait in Google Assistant. (#18772) * Add support for Mode trait in Google Assistant. * Simplify supported logic. * Fix SUPPORTED_MODE_SETTINGS to correct rip failures. * more stray commas * update tests. --- .../components/google_assistant/trait.py | 188 +++++++++++++++++- homeassistant/components/media_player/demo.py | 4 +- tests/components/google_assistant/__init__.py | 12 +- .../components/google_assistant/test_trait.py | 88 ++++++++ 4 files changed, 286 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py index e0d12e00e30..c0d496d2cfb 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -43,6 +43,7 @@ TRAIT_SCENE = PREFIX_TRAITS + 'Scene' TRAIT_TEMPERATURE_SETTING = PREFIX_TRAITS + 'TemperatureSetting' TRAIT_LOCKUNLOCK = PREFIX_TRAITS + 'LockUnlock' TRAIT_FANSPEED = PREFIX_TRAITS + 'FanSpeed' +TRAIT_MODES = PREFIX_TRAITS + 'Modes' PREFIX_COMMANDS = 'action.devices.commands.' COMMAND_ONOFF = PREFIX_COMMANDS + 'OnOff' @@ -59,7 +60,7 @@ COMMAND_THERMOSTAT_TEMPERATURE_SET_RANGE = ( COMMAND_THERMOSTAT_SET_MODE = PREFIX_COMMANDS + 'ThermostatSetMode' COMMAND_LOCKUNLOCK = PREFIX_COMMANDS + 'LockUnlock' COMMAND_FANSPEED = PREFIX_COMMANDS + 'SetFanSpeed' - +COMMAND_MODES = PREFIX_COMMANDS + 'SetModes' TRAITS = [] @@ -752,3 +753,188 @@ class FanSpeedTrait(_Trait): ATTR_ENTITY_ID: self.state.entity_id, fan.ATTR_SPEED: params['fanSpeed'] }, blocking=True) + + +@register_trait +class ModesTrait(_Trait): + """Trait to set modes. + + https://developers.google.com/actions/smarthome/traits/modes + """ + + name = TRAIT_MODES + commands = [ + COMMAND_MODES + ] + + # Google requires specific mode names and settings. Here is the full list. + # https://developers.google.com/actions/reference/smarthome/traits/modes + # All settings are mapped here as of 2018-11-28 and can be used for other + # entity types. + + HA_TO_GOOGLE = { + media_player.ATTR_INPUT_SOURCE: "input source", + } + SUPPORTED_MODE_SETTINGS = { + 'xsmall': [ + 'xsmall', 'extra small', 'min', 'minimum', 'tiny', 'xs'], + 'small': ['small', 'half'], + 'large': ['large', 'big', 'full'], + 'xlarge': ['extra large', 'xlarge', 'xl'], + 'Cool': ['cool', 'rapid cool', 'rapid cooling'], + 'Heat': ['heat'], 'Low': ['low'], + 'Medium': ['medium', 'med', 'mid', 'half'], + 'High': ['high'], + 'Auto': ['auto', 'automatic'], + 'Bake': ['bake'], 'Roast': ['roast'], + 'Convection Bake': ['convection bake', 'convect bake'], + 'Convection Roast': ['convection roast', 'convect roast'], + 'Favorite': ['favorite'], + 'Broil': ['broil'], + 'Warm': ['warm'], + 'Off': ['off'], + 'On': ['on'], + 'Normal': [ + 'normal', 'normal mode', 'normal setting', 'standard', + 'schedule', 'original', 'default', 'old settings' + ], + 'None': ['none'], + 'Tap Cold': ['tap cold'], + 'Cold Warm': ['cold warm'], + 'Hot': ['hot'], + 'Extra Hot': ['extra hot'], + 'Eco': ['eco'], + 'Wool': ['wool', 'fleece'], + 'Turbo': ['turbo'], + 'Rinse': ['rinse', 'rinsing', 'rinse wash'], + 'Away': ['away', 'holiday'], + 'maximum': ['maximum'], + 'media player': ['media player'], + 'chromecast': ['chromecast'], + 'tv': [ + 'tv', 'television', 'tv position', 'television position', + 'watching tv', 'watching tv position', 'entertainment', + 'entertainment position' + ], + 'am fm': ['am fm', 'am radio', 'fm radio'], + 'internet radio': ['internet radio'], + 'satellite': ['satellite'], + 'game console': ['game console'], + 'antifrost': ['antifrost', 'anti-frost'], + 'boost': ['boost'], + 'Clock': ['clock'], + 'Message': ['message'], + 'Messages': ['messages'], + 'News': ['news'], + 'Disco': ['disco'], + 'antifreeze': ['antifreeze', 'anti-freeze', 'anti freeze'], + 'balanced': ['balanced', 'normal'], + 'swing': ['swing'], + 'media': ['media', 'media mode'], + 'panic': ['panic'], + 'ring': ['ring'], + 'frozen': ['frozen', 'rapid frozen', 'rapid freeze'], + 'cotton': ['cotton', 'cottons'], + 'blend': ['blend', 'mix'], + 'baby wash': ['baby wash'], + 'synthetics': ['synthetic', 'synthetics', 'compose'], + 'hygiene': ['hygiene', 'sterilization'], + 'smart': ['smart', 'intelligent', 'intelligence'], + 'comfortable': ['comfortable', 'comfort'], + 'manual': ['manual'], + 'energy saving': ['energy saving'], + 'sleep': ['sleep'], + 'quick wash': ['quick wash', 'fast wash'], + 'cold': ['cold'], + 'airsupply': ['airsupply', 'air supply'], + 'dehumidification': ['dehumidication', 'dehumidify'], + 'game': ['game', 'game mode'] + } + + @staticmethod + def supported(domain, features): + """Test if state is supported.""" + if domain != media_player.DOMAIN: + return False + + return features & media_player.SUPPORT_SELECT_SOURCE + + def sync_attributes(self): + """Return mode attributes for a sync request.""" + sources_list = self.state.attributes.get( + media_player.ATTR_INPUT_SOURCE_LIST, []) + modes = [] + sources = {} + + if sources_list: + sources = { + "name": self.HA_TO_GOOGLE.get(media_player.ATTR_INPUT_SOURCE), + "name_values": [{ + "name_synonym": ['input source'], + "lang": "en" + }], + "settings": [], + "ordered": False + } + for source in sources_list: + if source in self.SUPPORTED_MODE_SETTINGS: + src = source + synonyms = self.SUPPORTED_MODE_SETTINGS.get(src) + elif source.lower() in self.SUPPORTED_MODE_SETTINGS: + src = source.lower() + synonyms = self.SUPPORTED_MODE_SETTINGS.get(src) + + else: + continue + + sources['settings'].append( + { + "setting_name": src, + "setting_values": [{ + "setting_synonym": synonyms, + "lang": "en" + }] + } + ) + if sources: + modes.append(sources) + payload = {'availableModes': modes} + + return payload + + def query_attributes(self): + """Return current modes.""" + attrs = self.state.attributes + response = {} + mode_settings = {} + + if attrs.get(media_player.ATTR_INPUT_SOURCE_LIST): + mode_settings.update({ + media_player.ATTR_INPUT_SOURCE: attrs.get( + media_player.ATTR_INPUT_SOURCE) + }) + if mode_settings: + response['on'] = self.state.state != STATE_OFF + response['online'] = True + response['currentModeSettings'] = mode_settings + + return response + + async def execute(self, command, params): + """Execute an SetModes command.""" + settings = params.get('updateModeSettings') + requested_source = settings.get( + self.HA_TO_GOOGLE.get(media_player.ATTR_INPUT_SOURCE)) + + if requested_source: + for src in self.state.attributes.get( + media_player.ATTR_INPUT_SOURCE_LIST): + if src.lower() == requested_source.lower(): + source = src + + await self.hass.services.async_call( + media_player.DOMAIN, + media_player.SERVICE_SELECT_SOURCE, { + ATTR_ENTITY_ID: self.state.entity_id, + media_player.ATTR_INPUT_SOURCE: source + }, blocking=True) diff --git a/homeassistant/components/media_player/demo.py b/homeassistant/components/media_player/demo.py index c2a736f531e..8a88e3bd74e 100644 --- a/homeassistant/components/media_player/demo.py +++ b/homeassistant/components/media_player/demo.py @@ -4,6 +4,7 @@ Demo implementation of the media player. For more details about this platform, please refer to the documentation https://home-assistant.io/components/demo/ """ +import homeassistant.util.dt as dt_util from homeassistant.components.media_player import ( MEDIA_TYPE_MOVIE, MEDIA_TYPE_MUSIC, MEDIA_TYPE_TVSHOW, SUPPORT_CLEAR_PLAYLIST, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PLAY, @@ -12,7 +13,6 @@ from homeassistant.components.media_player import ( SUPPORT_TURN_ON, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, MediaPlayerDevice) from homeassistant.const import STATE_OFF, STATE_PAUSED, STATE_PLAYING -import homeassistant.util.dt as dt_util def setup_platform(hass, config, add_entities, discovery_info=None): @@ -34,7 +34,7 @@ DEFAULT_SOUND_MODE = 'Dummy Music' YOUTUBE_PLAYER_SUPPORT = \ SUPPORT_PAUSE | SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE | \ SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_PLAY_MEDIA | SUPPORT_PLAY | \ - SUPPORT_SHUFFLE_SET | SUPPORT_SELECT_SOUND_MODE + SUPPORT_SHUFFLE_SET | SUPPORT_SELECT_SOUND_MODE | SUPPORT_SELECT_SOURCE MUSIC_PLAYER_SUPPORT = \ SUPPORT_PAUSE | SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE | \ diff --git a/tests/components/google_assistant/__init__.py b/tests/components/google_assistant/__init__.py index c8748ade00e..03cc327a5c5 100644 --- a/tests/components/google_assistant/__init__.py +++ b/tests/components/google_assistant/__init__.py @@ -141,7 +141,10 @@ DEMO_DEVICES = [{ 'name': 'Bedroom' }, 'traits': - ['action.devices.traits.OnOff', 'action.devices.traits.Brightness'], + [ + 'action.devices.traits.OnOff', 'action.devices.traits.Brightness', + 'action.devices.traits.Modes' + ], 'type': 'action.devices.types.SWITCH', 'willReportState': @@ -153,7 +156,10 @@ DEMO_DEVICES = [{ 'name': 'Living Room' }, 'traits': - ['action.devices.traits.OnOff', 'action.devices.traits.Brightness'], + [ + 'action.devices.traits.OnOff', 'action.devices.traits.Brightness', + 'action.devices.traits.Modes' + ], 'type': 'action.devices.types.SWITCH', 'willReportState': @@ -163,7 +169,7 @@ DEMO_DEVICES = [{ 'name': { 'name': 'Lounge room' }, - 'traits': ['action.devices.traits.OnOff'], + 'traits': ['action.devices.traits.OnOff', 'action.devices.traits.Modes'], 'type': 'action.devices.types.SWITCH', 'willReportState': False }, { diff --git a/tests/components/google_assistant/test_trait.py b/tests/components/google_assistant/test_trait.py index ef6ed7a4b8f..5bf7b2fe566 100644 --- a/tests/components/google_assistant/test_trait.py +++ b/tests/components/google_assistant/test_trait.py @@ -916,3 +916,91 @@ async def test_fan_speed(hass): 'entity_id': 'fan.living_room_fan', 'speed': 'medium' } + + +async def test_modes(hass): + """Test Mode trait.""" + assert trait.ModesTrait.supported( + media_player.DOMAIN, media_player.SUPPORT_SELECT_SOURCE) + + trt = trait.ModesTrait( + hass, State( + 'media_player.living_room', media_player.STATE_PLAYING, + attributes={ + media_player.ATTR_INPUT_SOURCE_LIST: [ + 'media', 'game', 'chromecast', 'plex' + ], + media_player.ATTR_INPUT_SOURCE: 'game' + }), + BASIC_CONFIG) + + attribs = trt.sync_attributes() + assert attribs == { + 'availableModes': [ + { + 'name': 'input source', + 'name_values': [ + { + 'name_synonym': ['input source'], + 'lang': 'en' + } + ], + 'settings': [ + { + 'setting_name': 'media', + 'setting_values': [ + { + 'setting_synonym': ['media', 'media mode'], + 'lang': 'en' + } + ] + }, + { + 'setting_name': 'game', + 'setting_values': [ + { + 'setting_synonym': ['game', 'game mode'], + 'lang': 'en' + } + ] + }, + { + 'setting_name': 'chromecast', + 'setting_values': [ + { + 'setting_synonym': ['chromecast'], + 'lang': 'en' + } + ] + } + ], + 'ordered': False + } + ] + } + + assert trt.query_attributes() == { + 'currentModeSettings': {'source': 'game'}, + 'on': True, + 'online': True + } + + assert trt.can_execute( + trait.COMMAND_MODES, params={ + 'updateModeSettings': { + trt.HA_TO_GOOGLE.get(media_player.ATTR_INPUT_SOURCE): 'media' + }}) + + calls = async_mock_service( + hass, media_player.DOMAIN, media_player.SERVICE_SELECT_SOURCE) + await trt.execute( + trait.COMMAND_MODES, params={ + 'updateModeSettings': { + trt.HA_TO_GOOGLE.get(media_player.ATTR_INPUT_SOURCE): 'media' + }}) + + assert len(calls) == 1 + assert calls[0].data == { + 'entity_id': 'media_player.living_room', + 'source': 'media' + }