Volume trait for google assistant (#23237)

* Add action.devices.traits.Volume

* Drop media player from brightness trait

* Factor out commands into separate functions

* Drop support for explicit mute
This commit is contained in:
Joakim Plate 2019-04-24 18:08:41 +02:00 committed by Paulus Schoutsen
parent 2863ac1068
commit e11e6e1b04
4 changed files with 145 additions and 51 deletions

View File

@ -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:

View File

@ -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':

View File

@ -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
}
}]
}, {

View File

@ -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
}