diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py index bad186a4edb..ac2f65af058 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -60,6 +60,7 @@ TRAIT_LOCKUNLOCK = PREFIX_TRAITS + 'LockUnlock' TRAIT_FANSPEED = PREFIX_TRAITS + 'FanSpeed' TRAIT_MODES = PREFIX_TRAITS + 'Modes' TRAIT_OPENCLOSE = PREFIX_TRAITS + 'OpenClose' +TRAIT_VOLUME = PREFIX_TRAITS + 'Volume' PREFIX_COMMANDS = 'action.devices.commands.' COMMAND_ONOFF = PREFIX_COMMANDS + 'OnOff' @@ -79,6 +80,8 @@ COMMAND_LOCKUNLOCK = PREFIX_COMMANDS + 'LockUnlock' COMMAND_FANSPEED = PREFIX_COMMANDS + 'SetFanSpeed' COMMAND_MODES = PREFIX_COMMANDS + 'SetModes' COMMAND_OPENCLOSE = PREFIX_COMMANDS + 'OpenClose' +COMMAND_SET_VOLUME = PREFIX_COMMANDS + 'setVolume' +COMMAND_VOLUME_RELATIVE = PREFIX_COMMANDS + 'volumeRelative' TRAITS = [] @@ -141,8 +144,6 @@ class BrightnessTrait(_Trait): """Test if state is supported.""" if domain == light.DOMAIN: return features & light.SUPPORT_BRIGHTNESS - if domain == media_player.DOMAIN: - return features & media_player.SUPPORT_VOLUME_SET return False @@ -160,13 +161,6 @@ class BrightnessTrait(_Trait): if brightness is not None: response['brightness'] = int(100 * (brightness / 255)) - elif domain == media_player.DOMAIN: - level = self.state.attributes.get( - media_player.ATTR_MEDIA_VOLUME_LEVEL) - if level is not None: - # Convert 0.0-1.0 to 0-255 - response['brightness'] = int(level * 100) - return response async def execute(self, command, data, params, challenge): @@ -179,13 +173,6 @@ class BrightnessTrait(_Trait): ATTR_ENTITY_ID: self.state.entity_id, light.ATTR_BRIGHTNESS_PCT: params['brightness'] }, blocking=True, context=data.context) - elif domain == media_player.DOMAIN: - await self.hass.services.async_call( - media_player.DOMAIN, media_player.SERVICE_VOLUME_SET, { - ATTR_ENTITY_ID: self.state.entity_id, - media_player.ATTR_MEDIA_VOLUME_LEVEL: - params['brightness'] / 100 - }, blocking=True, context=data.context) @register_trait @@ -1132,6 +1119,81 @@ class OpenCloseTrait(_Trait): 'Setting a position is not supported') +@register_trait +class VolumeTrait(_Trait): + """Trait to control brightness of a device. + + https://developers.google.com/actions/smarthome/traits/volume + """ + + name = TRAIT_VOLUME + commands = [ + COMMAND_SET_VOLUME, + COMMAND_VOLUME_RELATIVE, + ] + + @staticmethod + def supported(domain, features, device_class): + """Test if state is supported.""" + if domain == media_player.DOMAIN: + return features & media_player.SUPPORT_VOLUME_SET + + return False + + def sync_attributes(self): + """Return brightness attributes for a sync request.""" + return {} + + def query_attributes(self): + """Return brightness query attributes.""" + response = {} + + level = self.state.attributes.get( + media_player.ATTR_MEDIA_VOLUME_LEVEL) + muted = self.state.attributes.get( + media_player.ATTR_MEDIA_VOLUME_MUTED) + if level is not None: + # Convert 0.0-1.0 to 0-100 + response['currentVolume'] = int(level * 100) + response['isMuted'] = bool(muted) + + return response + + async def _execute_set_volume(self, data, params): + level = params['volumeLevel'] + + await self.hass.services.async_call( + media_player.DOMAIN, + media_player.SERVICE_VOLUME_SET, { + ATTR_ENTITY_ID: self.state.entity_id, + media_player.ATTR_MEDIA_VOLUME_LEVEL: + level / 100 + }, blocking=True, context=data.context) + + async def _execute_volume_relative(self, data, params): + # This could also support up/down commands using relativeSteps + relative = params['volumeRelativeLevel'] + current = self.state.attributes.get( + media_player.ATTR_MEDIA_VOLUME_LEVEL) + + await self.hass.services.async_call( + media_player.DOMAIN, media_player.SERVICE_VOLUME_SET, { + ATTR_ENTITY_ID: self.state.entity_id, + media_player.ATTR_MEDIA_VOLUME_LEVEL: + current + relative / 100 + }, blocking=True, context=data.context) + + async def execute(self, command, data, params, challenge): + """Execute a brightness command.""" + if command == COMMAND_SET_VOLUME: + await self._execute_set_volume(data, params) + elif command == COMMAND_VOLUME_RELATIVE: + await self._execute_volume_relative(data, params) + else: + raise SmartHomeError( + ERR_NOT_SUPPORTED, 'Command not supported') + + def _verify_pin_challenge(data, challenge): """Verify a pin challenge.""" if not data.config.secure_devices_pin: diff --git a/tests/components/google_assistant/__init__.py b/tests/components/google_assistant/__init__.py index d75b51df65b..f3732c12213 100644 --- a/tests/components/google_assistant/__init__.py +++ b/tests/components/google_assistant/__init__.py @@ -143,7 +143,7 @@ DEMO_DEVICES = [{ }, 'traits': [ - 'action.devices.traits.OnOff', 'action.devices.traits.Brightness', + 'action.devices.traits.OnOff', 'action.devices.traits.Volume', 'action.devices.traits.Modes' ], 'type': @@ -158,7 +158,7 @@ DEMO_DEVICES = [{ }, 'traits': [ - 'action.devices.traits.OnOff', 'action.devices.traits.Brightness', + 'action.devices.traits.OnOff', 'action.devices.traits.Volume', 'action.devices.traits.Modes' ], 'type': @@ -180,7 +180,7 @@ DEMO_DEVICES = [{ 'name': 'Walkman' }, 'traits': - ['action.devices.traits.OnOff', 'action.devices.traits.Brightness'], + ['action.devices.traits.OnOff', 'action.devices.traits.Volume'], 'type': 'action.devices.types.SWITCH', 'willReportState': diff --git a/tests/components/google_assistant/test_google_assistant.py b/tests/components/google_assistant/test_google_assistant.py index 19e1858d4f5..4e2c04e5cf4 100644 --- a/tests/components/google_assistant/test_google_assistant.py +++ b/tests/components/google_assistant/test_google_assistant.py @@ -319,9 +319,9 @@ def test_execute_request(hass_fixture, assistant_client, auth_header): }], "execution": [{ "command": - "action.devices.commands.BrightnessAbsolute", + "action.devices.commands.setVolume", "params": { - "brightness": 70 + "volumeLevel": 70 } }] }, { diff --git a/tests/components/google_assistant/test_trait.py b/tests/components/google_assistant/test_trait.py index 8b7f0788f34..96ca8d82f5e 100644 --- a/tests/components/google_assistant/test_trait.py +++ b/tests/components/google_assistant/test_trait.py @@ -92,36 +92,6 @@ async def test_brightness_light(hass): } -async def test_brightness_media_player(hass): - """Test brightness trait support for media player domain.""" - assert helpers.get_google_type(media_player.DOMAIN, None) is not None - assert trait.BrightnessTrait.supported(media_player.DOMAIN, - media_player.SUPPORT_VOLUME_SET, - None) - - trt = trait.BrightnessTrait(hass, State( - 'media_player.bla', media_player.STATE_PLAYING, { - media_player.ATTR_MEDIA_VOLUME_LEVEL: .3 - }), BASIC_CONFIG) - - assert trt.sync_attributes() == {} - - assert trt.query_attributes() == { - 'brightness': 30 - } - - calls = async_mock_service( - hass, media_player.DOMAIN, media_player.SERVICE_VOLUME_SET) - await trt.execute( - trait.COMMAND_BRIGHTNESS_ABSOLUTE, BASIC_DATA, - {'brightness': 60}, {}) - assert len(calls) == 1 - assert calls[0].data == { - ATTR_ENTITY_ID: 'media_player.bla', - media_player.ATTR_MEDIA_VOLUME_LEVEL: .6 - } - - async def test_camera_stream(hass): """Test camera stream trait support for camera domain.""" hass.config.api = Mock(base_url='http://1.1.1.1:8123') @@ -1276,3 +1246,65 @@ async def test_openclose_binary_sensor(hass, device_class): assert trt.query_attributes() == { 'openPercent': 0 } + + +async def test_volume_media_player(hass): + """Test volume trait support for media player domain.""" + assert helpers.get_google_type(media_player.DOMAIN, None) is not None + assert trait.VolumeTrait.supported(media_player.DOMAIN, + media_player.SUPPORT_VOLUME_SET | + media_player.SUPPORT_VOLUME_MUTE, + None) + + trt = trait.VolumeTrait(hass, State( + 'media_player.bla', media_player.STATE_PLAYING, { + media_player.ATTR_MEDIA_VOLUME_LEVEL: .3, + media_player.ATTR_MEDIA_VOLUME_MUTED: False, + }), BASIC_CONFIG) + + assert trt.sync_attributes() == {} + + assert trt.query_attributes() == { + 'currentVolume': 30, + 'isMuted': False + } + + calls = async_mock_service( + hass, media_player.DOMAIN, media_player.SERVICE_VOLUME_SET) + await trt.execute( + trait.COMMAND_SET_VOLUME, BASIC_DATA, + {'volumeLevel': 60}, {}) + assert len(calls) == 1 + assert calls[0].data == { + ATTR_ENTITY_ID: 'media_player.bla', + media_player.ATTR_MEDIA_VOLUME_LEVEL: .6 + } + + +async def test_volume_media_player_relative(hass): + """Test volume trait support for media player domain.""" + trt = trait.VolumeTrait(hass, State( + 'media_player.bla', media_player.STATE_PLAYING, { + media_player.ATTR_MEDIA_VOLUME_LEVEL: .3, + media_player.ATTR_MEDIA_VOLUME_MUTED: False, + }), BASIC_CONFIG) + + assert trt.sync_attributes() == {} + + assert trt.query_attributes() == { + 'currentVolume': 30, + 'isMuted': False + } + + calls = async_mock_service( + hass, media_player.DOMAIN, media_player.SERVICE_VOLUME_SET) + + await trt.execute( + trait.COMMAND_VOLUME_RELATIVE, BASIC_DATA, + {'volumeRelativeLevel': 20, + 'relativeSteps': 2}, {}) + assert len(calls) == 1 + assert calls[0].data == { + ATTR_ENTITY_ID: 'media_player.bla', + media_player.ATTR_MEDIA_VOLUME_LEVEL: .5 + }