Support muting and relative-volume media_players in Google Assistant (#38651)
Support the action.devices.commands.mute intent to mute and unmute media_players that declare support for mute/unmute. For media players with support for volume up/down, but no support for setting the volume to a specific number, allow use of the action.devices.commands.relativeMute intent to control volume up/down. This will improve support for IR blasters and other open-loop media_player integrations.pull/38929/head
parent
a6f869aeee
commit
53b729a0d1
|
@ -121,6 +121,7 @@ COMMAND_PREVIOUS_INPUT = f"{PREFIX_COMMANDS}PreviousInput"
|
|||
COMMAND_OPENCLOSE = f"{PREFIX_COMMANDS}OpenClose"
|
||||
COMMAND_SET_VOLUME = f"{PREFIX_COMMANDS}setVolume"
|
||||
COMMAND_VOLUME_RELATIVE = f"{PREFIX_COMMANDS}volumeRelative"
|
||||
COMMAND_MUTE = f"{PREFIX_COMMANDS}mute"
|
||||
COMMAND_ARMDISARM = f"{PREFIX_COMMANDS}ArmDisarm"
|
||||
COMMAND_MEDIA_NEXT = f"{PREFIX_COMMANDS}mediaNext"
|
||||
COMMAND_MEDIA_PAUSE = f"{PREFIX_COMMANDS}mediaPause"
|
||||
|
@ -1627,75 +1628,132 @@ class OpenCloseTrait(_Trait):
|
|||
|
||||
@register_trait
|
||||
class VolumeTrait(_Trait):
|
||||
"""Trait to control brightness of a device.
|
||||
"""Trait to control volume of a device.
|
||||
|
||||
https://developers.google.com/actions/smarthome/traits/volume
|
||||
"""
|
||||
|
||||
name = TRAIT_VOLUME
|
||||
commands = [COMMAND_SET_VOLUME, COMMAND_VOLUME_RELATIVE]
|
||||
commands = [COMMAND_SET_VOLUME, COMMAND_VOLUME_RELATIVE, COMMAND_MUTE]
|
||||
|
||||
@staticmethod
|
||||
def supported(domain, features, device_class):
|
||||
"""Test if state is supported."""
|
||||
"""Test if trait is supported."""
|
||||
if domain == media_player.DOMAIN:
|
||||
return features & media_player.SUPPORT_VOLUME_SET
|
||||
return features & (
|
||||
media_player.SUPPORT_VOLUME_SET | media_player.SUPPORT_VOLUME_STEP
|
||||
)
|
||||
|
||||
return False
|
||||
|
||||
def sync_attributes(self):
|
||||
"""Return brightness attributes for a sync request."""
|
||||
return {}
|
||||
"""Return volume attributes for a sync request."""
|
||||
features = self.state.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
|
||||
return {
|
||||
"volumeCanMuteAndUnmute": bool(features & media_player.SUPPORT_VOLUME_MUTE),
|
||||
"commandOnlyVolume": self.state.attributes.get(ATTR_ASSUMED_STATE, False),
|
||||
# Volume amounts in SET_VOLUME and VOLUME_RELATIVE are on a scale
|
||||
# from 0 to this value.
|
||||
"volumeMaxLevel": 100,
|
||||
# Default change for queries like "Hey Google, volume up".
|
||||
# 10% corresponds to the default behavior for the
|
||||
# media_player.volume{up,down} services.
|
||||
"levelStepSize": 10,
|
||||
}
|
||||
|
||||
def query_attributes(self):
|
||||
"""Return brightness query attributes."""
|
||||
"""Return volume 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)
|
||||
|
||||
muted = self.state.attributes.get(media_player.ATTR_MEDIA_VOLUME_MUTED)
|
||||
if muted is not None:
|
||||
response["isMuted"] = bool(muted)
|
||||
|
||||
return response
|
||||
|
||||
async def _execute_set_volume(self, data, params):
|
||||
level = params["volumeLevel"]
|
||||
|
||||
async def _set_volume_absolute(self, data, 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: level / 100,
|
||||
media_player.ATTR_MEDIA_VOLUME_LEVEL: level,
|
||||
},
|
||||
blocking=True,
|
||||
context=data.context,
|
||||
)
|
||||
|
||||
async def _execute_set_volume(self, data, params):
|
||||
level = max(0, min(100, params["volumeLevel"]))
|
||||
|
||||
if not (
|
||||
self.state.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
|
||||
& media_player.SUPPORT_VOLUME_SET
|
||||
):
|
||||
raise SmartHomeError(ERR_NOT_SUPPORTED, "Command not supported")
|
||||
|
||||
await self._set_volume_absolute(data, level / 100)
|
||||
|
||||
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)
|
||||
relative = params["relativeSteps"]
|
||||
features = self.state.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
|
||||
|
||||
if features & media_player.SUPPORT_VOLUME_SET:
|
||||
current = self.state.attributes.get(media_player.ATTR_MEDIA_VOLUME_LEVEL)
|
||||
target = max(0.0, min(1.0, current + relative / 100))
|
||||
|
||||
await self._set_volume_absolute(data, target)
|
||||
|
||||
elif features & media_player.SUPPORT_VOLUME_STEP:
|
||||
svc = media_player.SERVICE_VOLUME_UP
|
||||
if relative < 0:
|
||||
svc = media_player.SERVICE_VOLUME_DOWN
|
||||
relative = -relative
|
||||
|
||||
for i in range(relative):
|
||||
await self.hass.services.async_call(
|
||||
media_player.DOMAIN,
|
||||
svc,
|
||||
{ATTR_ENTITY_ID: self.state.entity_id},
|
||||
blocking=True,
|
||||
context=data.context,
|
||||
)
|
||||
else:
|
||||
raise SmartHomeError(ERR_NOT_SUPPORTED, "Command not supported")
|
||||
|
||||
async def _execute_mute(self, data, params):
|
||||
mute = params["mute"]
|
||||
|
||||
if not (
|
||||
self.state.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
|
||||
& media_player.SUPPORT_VOLUME_MUTE
|
||||
):
|
||||
raise SmartHomeError(ERR_NOT_SUPPORTED, "Command not supported")
|
||||
|
||||
await self.hass.services.async_call(
|
||||
media_player.DOMAIN,
|
||||
media_player.SERVICE_VOLUME_SET,
|
||||
media_player.SERVICE_VOLUME_MUTE,
|
||||
{
|
||||
ATTR_ENTITY_ID: self.state.entity_id,
|
||||
media_player.ATTR_MEDIA_VOLUME_LEVEL: current + relative / 100,
|
||||
media_player.ATTR_MEDIA_VOLUME_MUTED: mute,
|
||||
},
|
||||
blocking=True,
|
||||
context=data.context,
|
||||
)
|
||||
|
||||
async def execute(self, command, data, params, challenge):
|
||||
"""Execute a brightness command."""
|
||||
"""Execute a volume 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)
|
||||
elif command == COMMAND_MUTE:
|
||||
await self._execute_mute(data, params)
|
||||
else:
|
||||
raise SmartHomeError(ERR_NOT_SUPPORTED, "Command not supported")
|
||||
|
||||
|
|
|
@ -2003,9 +2003,7 @@ 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,
|
||||
media_player.DOMAIN, media_player.SUPPORT_VOLUME_SET, None,
|
||||
)
|
||||
|
||||
trt = trait.VolumeTrait(
|
||||
|
@ -2014,16 +2012,21 @@ async def test_volume_media_player(hass):
|
|||
"media_player.bla",
|
||||
media_player.STATE_PLAYING,
|
||||
{
|
||||
ATTR_SUPPORTED_FEATURES: media_player.SUPPORT_VOLUME_SET,
|
||||
media_player.ATTR_MEDIA_VOLUME_LEVEL: 0.3,
|
||||
media_player.ATTR_MEDIA_VOLUME_MUTED: False,
|
||||
},
|
||||
),
|
||||
BASIC_CONFIG,
|
||||
)
|
||||
|
||||
assert trt.sync_attributes() == {}
|
||||
assert trt.sync_attributes() == {
|
||||
"volumeMaxLevel": 100,
|
||||
"levelStepSize": 10,
|
||||
"volumeCanMuteAndUnmute": False,
|
||||
"commandOnlyVolume": False,
|
||||
}
|
||||
|
||||
assert trt.query_attributes() == {"currentVolume": 30, "isMuted": False}
|
||||
assert trt.query_attributes() == {"currentVolume": 30}
|
||||
|
||||
calls = async_mock_service(
|
||||
hass, media_player.DOMAIN, media_player.SERVICE_VOLUME_SET
|
||||
|
@ -2035,40 +2038,130 @@ async def test_volume_media_player(hass):
|
|||
media_player.ATTR_MEDIA_VOLUME_LEVEL: 0.6,
|
||||
}
|
||||
|
||||
calls = async_mock_service(
|
||||
hass, media_player.DOMAIN, media_player.SERVICE_VOLUME_SET
|
||||
)
|
||||
await trt.execute(
|
||||
trait.COMMAND_VOLUME_RELATIVE, BASIC_DATA, {"relativeSteps": 10}, {}
|
||||
)
|
||||
assert len(calls) == 1
|
||||
assert calls[0].data == {
|
||||
ATTR_ENTITY_ID: "media_player.bla",
|
||||
media_player.ATTR_MEDIA_VOLUME_LEVEL: 0.4,
|
||||
}
|
||||
|
||||
|
||||
async def test_volume_media_player_relative(hass):
|
||||
"""Test volume trait support for media player domain."""
|
||||
"""Test volume trait support for relative-volume-only media players."""
|
||||
assert trait.VolumeTrait.supported(
|
||||
media_player.DOMAIN, media_player.SUPPORT_VOLUME_STEP, None,
|
||||
)
|
||||
trt = trait.VolumeTrait(
|
||||
hass,
|
||||
State(
|
||||
"media_player.bla",
|
||||
media_player.STATE_PLAYING,
|
||||
{
|
||||
media_player.ATTR_MEDIA_VOLUME_LEVEL: 0.3,
|
||||
ATTR_ASSUMED_STATE: True,
|
||||
ATTR_SUPPORTED_FEATURES: media_player.SUPPORT_VOLUME_STEP,
|
||||
},
|
||||
),
|
||||
BASIC_CONFIG,
|
||||
)
|
||||
|
||||
assert trt.sync_attributes() == {
|
||||
"volumeMaxLevel": 100,
|
||||
"levelStepSize": 10,
|
||||
"volumeCanMuteAndUnmute": False,
|
||||
"commandOnlyVolume": True,
|
||||
}
|
||||
|
||||
assert trt.query_attributes() == {}
|
||||
|
||||
calls = async_mock_service(
|
||||
hass, media_player.DOMAIN, media_player.SERVICE_VOLUME_UP
|
||||
)
|
||||
|
||||
await trt.execute(
|
||||
trait.COMMAND_VOLUME_RELATIVE, BASIC_DATA, {"relativeSteps": 10}, {},
|
||||
)
|
||||
assert len(calls) == 10
|
||||
for call in calls:
|
||||
assert call.data == {
|
||||
ATTR_ENTITY_ID: "media_player.bla",
|
||||
}
|
||||
|
||||
calls = async_mock_service(
|
||||
hass, media_player.DOMAIN, media_player.SERVICE_VOLUME_DOWN
|
||||
)
|
||||
await trt.execute(
|
||||
trait.COMMAND_VOLUME_RELATIVE, BASIC_DATA, {"relativeSteps": -10}, {},
|
||||
)
|
||||
assert len(calls) == 10
|
||||
for call in calls:
|
||||
assert call.data == {
|
||||
ATTR_ENTITY_ID: "media_player.bla",
|
||||
}
|
||||
|
||||
with pytest.raises(SmartHomeError):
|
||||
await trt.execute(trait.COMMAND_SET_VOLUME, BASIC_DATA, {"volumeLevel": 42}, {})
|
||||
|
||||
with pytest.raises(SmartHomeError):
|
||||
await trt.execute(trait.COMMAND_MUTE, BASIC_DATA, {"mute": True}, {})
|
||||
|
||||
|
||||
async def test_media_player_mute(hass):
|
||||
"""Test volume trait support for muting."""
|
||||
assert trait.VolumeTrait.supported(
|
||||
media_player.DOMAIN,
|
||||
media_player.SUPPORT_VOLUME_STEP | media_player.SUPPORT_VOLUME_MUTE,
|
||||
None,
|
||||
)
|
||||
trt = trait.VolumeTrait(
|
||||
hass,
|
||||
State(
|
||||
"media_player.bla",
|
||||
media_player.STATE_PLAYING,
|
||||
{
|
||||
ATTR_SUPPORTED_FEATURES: (
|
||||
media_player.SUPPORT_VOLUME_STEP | media_player.SUPPORT_VOLUME_MUTE
|
||||
),
|
||||
media_player.ATTR_MEDIA_VOLUME_MUTED: False,
|
||||
},
|
||||
),
|
||||
BASIC_CONFIG,
|
||||
)
|
||||
|
||||
assert trt.sync_attributes() == {}
|
||||
assert trt.sync_attributes() == {
|
||||
"volumeMaxLevel": 100,
|
||||
"levelStepSize": 10,
|
||||
"volumeCanMuteAndUnmute": True,
|
||||
"commandOnlyVolume": False,
|
||||
}
|
||||
assert trt.query_attributes() == {"isMuted": False}
|
||||
|
||||
assert trt.query_attributes() == {"currentVolume": 30, "isMuted": False}
|
||||
|
||||
calls = async_mock_service(
|
||||
hass, media_player.DOMAIN, media_player.SERVICE_VOLUME_SET
|
||||
mute_calls = async_mock_service(
|
||||
hass, media_player.DOMAIN, media_player.SERVICE_VOLUME_MUTE
|
||||
)
|
||||
|
||||
await trt.execute(
|
||||
trait.COMMAND_VOLUME_RELATIVE,
|
||||
BASIC_DATA,
|
||||
{"volumeRelativeLevel": 20, "relativeSteps": 2},
|
||||
{},
|
||||
trait.COMMAND_MUTE, BASIC_DATA, {"mute": True}, {},
|
||||
)
|
||||
assert len(calls) == 1
|
||||
assert calls[0].data == {
|
||||
assert len(mute_calls) == 1
|
||||
assert mute_calls[0].data == {
|
||||
ATTR_ENTITY_ID: "media_player.bla",
|
||||
media_player.ATTR_MEDIA_VOLUME_LEVEL: 0.5,
|
||||
media_player.ATTR_MEDIA_VOLUME_MUTED: True,
|
||||
}
|
||||
|
||||
unmute_calls = async_mock_service(
|
||||
hass, media_player.DOMAIN, media_player.SERVICE_VOLUME_MUTE
|
||||
)
|
||||
await trt.execute(
|
||||
trait.COMMAND_MUTE, BASIC_DATA, {"mute": False}, {},
|
||||
)
|
||||
assert len(unmute_calls) == 1
|
||||
assert unmute_calls[0].data == {
|
||||
ATTR_ENTITY_ID: "media_player.bla",
|
||||
media_player.ATTR_MEDIA_VOLUME_MUTED: False,
|
||||
}
|
||||
|
||||
|
||||
|
|
Loading…
Reference in New Issue