mirror of
https://github.com/home-assistant/core.git
synced 2025-09-03 03:41:40 +02:00
Add volume up/down intents for media players (#150443)
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
"""Intents for the media_player integration."""
|
"""Intents for the media_player integration."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
from collections.abc import Iterable
|
from collections.abc import Iterable
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
import logging
|
import logging
|
||||||
@@ -14,21 +15,21 @@ from homeassistant.const import (
|
|||||||
SERVICE_MEDIA_PLAY,
|
SERVICE_MEDIA_PLAY,
|
||||||
SERVICE_MEDIA_PREVIOUS_TRACK,
|
SERVICE_MEDIA_PREVIOUS_TRACK,
|
||||||
SERVICE_VOLUME_SET,
|
SERVICE_VOLUME_SET,
|
||||||
|
STATE_PLAYING,
|
||||||
)
|
)
|
||||||
from homeassistant.core import Context, HomeAssistant, State
|
from homeassistant.core import Context, HomeAssistant, State
|
||||||
from homeassistant.exceptions import HomeAssistantError
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
from homeassistant.helpers import config_validation as cv, intent
|
from homeassistant.helpers import config_validation as cv, intent
|
||||||
|
from homeassistant.helpers.entity_component import EntityComponent
|
||||||
|
|
||||||
from . import (
|
from . import MediaPlayerDeviceClass, MediaPlayerEntity
|
||||||
|
from .browse_media import SearchMedia
|
||||||
|
from .const import (
|
||||||
|
ATTR_MEDIA_FILTER_CLASSES,
|
||||||
ATTR_MEDIA_VOLUME_LEVEL,
|
ATTR_MEDIA_VOLUME_LEVEL,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
SERVICE_PLAY_MEDIA,
|
SERVICE_PLAY_MEDIA,
|
||||||
SERVICE_SEARCH_MEDIA,
|
SERVICE_SEARCH_MEDIA,
|
||||||
MediaPlayerDeviceClass,
|
|
||||||
SearchMedia,
|
|
||||||
)
|
|
||||||
from .const import (
|
|
||||||
ATTR_MEDIA_FILTER_CLASSES,
|
|
||||||
MediaClass,
|
MediaClass,
|
||||||
MediaPlayerEntityFeature,
|
MediaPlayerEntityFeature,
|
||||||
MediaPlayerState,
|
MediaPlayerState,
|
||||||
@@ -39,6 +40,7 @@ INTENT_MEDIA_UNPAUSE = "HassMediaUnpause"
|
|||||||
INTENT_MEDIA_NEXT = "HassMediaNext"
|
INTENT_MEDIA_NEXT = "HassMediaNext"
|
||||||
INTENT_MEDIA_PREVIOUS = "HassMediaPrevious"
|
INTENT_MEDIA_PREVIOUS = "HassMediaPrevious"
|
||||||
INTENT_SET_VOLUME = "HassSetVolume"
|
INTENT_SET_VOLUME = "HassSetVolume"
|
||||||
|
INTENT_SET_VOLUME_RELATIVE = "HassSetVolumeRelative"
|
||||||
INTENT_MEDIA_SEARCH_AND_PLAY = "HassMediaSearchAndPlay"
|
INTENT_MEDIA_SEARCH_AND_PLAY = "HassMediaSearchAndPlay"
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
@@ -127,6 +129,7 @@ async def async_setup_intents(hass: HomeAssistant) -> None:
|
|||||||
device_classes={MediaPlayerDeviceClass},
|
device_classes={MediaPlayerDeviceClass},
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
intent.async_register(hass, MediaSetVolumeRelativeHandler())
|
||||||
intent.async_register(hass, MediaSearchAndPlayHandler())
|
intent.async_register(hass, MediaSearchAndPlayHandler())
|
||||||
|
|
||||||
|
|
||||||
@@ -354,3 +357,120 @@ class MediaSearchAndPlayHandler(intent.IntentHandler):
|
|||||||
response.async_set_speech_slots({"media": first_result.as_dict()})
|
response.async_set_speech_slots({"media": first_result.as_dict()})
|
||||||
response.response_type = intent.IntentResponseType.ACTION_DONE
|
response.response_type = intent.IntentResponseType.ACTION_DONE
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
class MediaSetVolumeRelativeHandler(intent.IntentHandler):
|
||||||
|
"""Handler for setting relative volume."""
|
||||||
|
|
||||||
|
description = "Increases or decreases the volume of a media player"
|
||||||
|
|
||||||
|
intent_type = INTENT_SET_VOLUME_RELATIVE
|
||||||
|
slot_schema = {
|
||||||
|
vol.Required("volume_step"): vol.Any(
|
||||||
|
"up",
|
||||||
|
"down",
|
||||||
|
vol.All(
|
||||||
|
vol.Coerce(int),
|
||||||
|
vol.Range(min=-100, max=100),
|
||||||
|
lambda val: val / 100,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
# Optional name/area/floor slots handled by intent matcher
|
||||||
|
vol.Optional("name"): cv.string,
|
||||||
|
vol.Optional("area"): cv.string,
|
||||||
|
vol.Optional("floor"): cv.string,
|
||||||
|
vol.Optional("preferred_area_id"): cv.string,
|
||||||
|
vol.Optional("preferred_floor_id"): cv.string,
|
||||||
|
}
|
||||||
|
platforms = {DOMAIN}
|
||||||
|
|
||||||
|
async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse:
|
||||||
|
"""Handle the intent."""
|
||||||
|
hass = intent_obj.hass
|
||||||
|
component: EntityComponent[MediaPlayerEntity] = hass.data[DOMAIN]
|
||||||
|
|
||||||
|
slots = self.async_validate_slots(intent_obj.slots)
|
||||||
|
volume_step = slots["volume_step"]["value"]
|
||||||
|
|
||||||
|
# Entity name to match
|
||||||
|
name_slot = slots.get("name", {})
|
||||||
|
entity_name: str | None = name_slot.get("value")
|
||||||
|
|
||||||
|
# Get area/floor info
|
||||||
|
area_slot = slots.get("area", {})
|
||||||
|
area_id = area_slot.get("value")
|
||||||
|
|
||||||
|
floor_slot = slots.get("floor", {})
|
||||||
|
floor_id = floor_slot.get("value")
|
||||||
|
|
||||||
|
# Find matching entities
|
||||||
|
match_constraints = intent.MatchTargetsConstraints(
|
||||||
|
name=entity_name,
|
||||||
|
area_name=area_id,
|
||||||
|
floor_name=floor_id,
|
||||||
|
domains={DOMAIN},
|
||||||
|
assistant=intent_obj.assistant,
|
||||||
|
features=MediaPlayerEntityFeature.VOLUME_SET,
|
||||||
|
)
|
||||||
|
match_preferences = intent.MatchTargetsPreferences(
|
||||||
|
area_id=slots.get("preferred_area_id", {}).get("value"),
|
||||||
|
floor_id=slots.get("preferred_floor_id", {}).get("value"),
|
||||||
|
)
|
||||||
|
match_result = intent.async_match_targets(
|
||||||
|
hass, match_constraints, match_preferences
|
||||||
|
)
|
||||||
|
|
||||||
|
if not match_result.is_match:
|
||||||
|
# No targets
|
||||||
|
raise intent.MatchFailedError(
|
||||||
|
result=match_result, constraints=match_constraints
|
||||||
|
)
|
||||||
|
|
||||||
|
if (
|
||||||
|
match_result.is_match
|
||||||
|
and (len(match_result.states) > 1)
|
||||||
|
and ("name" not in intent_obj.slots)
|
||||||
|
):
|
||||||
|
# Multiple targets not by name, so we need to check state
|
||||||
|
match_result.states = [
|
||||||
|
s for s in match_result.states if s.state == STATE_PLAYING
|
||||||
|
]
|
||||||
|
if not match_result.states:
|
||||||
|
# No media players are playing
|
||||||
|
raise intent.MatchFailedError(
|
||||||
|
result=intent.MatchTargetsResult(
|
||||||
|
is_match=False, no_match_reason=intent.MatchFailedReason.STATE
|
||||||
|
),
|
||||||
|
constraints=match_constraints,
|
||||||
|
preferences=match_preferences,
|
||||||
|
)
|
||||||
|
|
||||||
|
target_entity_ids = {s.entity_id for s in match_result.states}
|
||||||
|
target_entities = [
|
||||||
|
e for e in component.entities if e.entity_id in target_entity_ids
|
||||||
|
]
|
||||||
|
|
||||||
|
if volume_step == "up":
|
||||||
|
coros = [e.async_volume_up() for e in target_entities]
|
||||||
|
elif volume_step == "down":
|
||||||
|
coros = [e.async_volume_down() for e in target_entities]
|
||||||
|
else:
|
||||||
|
coros = [
|
||||||
|
e.async_set_volume_level(
|
||||||
|
max(0.0, min(1.0, e.volume_level + volume_step))
|
||||||
|
)
|
||||||
|
for e in target_entities
|
||||||
|
]
|
||||||
|
|
||||||
|
try:
|
||||||
|
await asyncio.gather(*coros)
|
||||||
|
except HomeAssistantError as err:
|
||||||
|
_LOGGER.error("Error setting relative volume: %s", err)
|
||||||
|
raise intent.IntentHandleError(
|
||||||
|
f"Error setting relative volume: {err}"
|
||||||
|
) from err
|
||||||
|
|
||||||
|
response = intent_obj.create_response()
|
||||||
|
response.response_type = intent.IntentResponseType.ACTION_DONE
|
||||||
|
response.async_set_states(match_result.states)
|
||||||
|
return response
|
||||||
|
@@ -1,5 +1,8 @@
|
|||||||
"""The tests for the media_player platform."""
|
"""The tests for the media_player platform."""
|
||||||
|
|
||||||
|
import math
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from homeassistant.components.media_player import (
|
from homeassistant.components.media_player import (
|
||||||
@@ -13,12 +16,17 @@ from homeassistant.components.media_player import (
|
|||||||
SERVICE_VOLUME_SET,
|
SERVICE_VOLUME_SET,
|
||||||
BrowseMedia,
|
BrowseMedia,
|
||||||
MediaClass,
|
MediaClass,
|
||||||
|
MediaPlayerEntity,
|
||||||
MediaType,
|
MediaType,
|
||||||
SearchMedia,
|
SearchMedia,
|
||||||
intent as media_player_intent,
|
intent as media_player_intent,
|
||||||
)
|
)
|
||||||
from homeassistant.components.media_player.const import MediaPlayerEntityFeature
|
from homeassistant.components.media_player.const import (
|
||||||
|
MediaPlayerEntityFeature,
|
||||||
|
MediaPlayerState,
|
||||||
|
)
|
||||||
from homeassistant.const import (
|
from homeassistant.const import (
|
||||||
|
ATTR_FRIENDLY_NAME,
|
||||||
ATTR_SUPPORTED_FEATURES,
|
ATTR_SUPPORTED_FEATURES,
|
||||||
STATE_IDLE,
|
STATE_IDLE,
|
||||||
STATE_PAUSED,
|
STATE_PAUSED,
|
||||||
@@ -32,8 +40,10 @@ from homeassistant.helpers import (
|
|||||||
floor_registry as fr,
|
floor_registry as fr,
|
||||||
intent,
|
intent,
|
||||||
)
|
)
|
||||||
|
from homeassistant.helpers.entity_component import EntityComponent
|
||||||
|
from homeassistant.setup import async_setup_component
|
||||||
|
|
||||||
from tests.common import async_mock_service
|
from tests.common import MockEntityPlatform, async_mock_service
|
||||||
|
|
||||||
|
|
||||||
async def test_pause_media_player_intent(hass: HomeAssistant) -> None:
|
async def test_pause_media_player_intent(hass: HomeAssistant) -> None:
|
||||||
@@ -873,3 +883,165 @@ async def test_search_and_play_media_player_intent_with_media_class(
|
|||||||
"media_class": {"value": "invalid_class"},
|
"media_class": {"value": "invalid_class"},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("direction", "volume_change", "volume_change_int"),
|
||||||
|
[("up", 0.1, 20), ("down", -0.1, -20)],
|
||||||
|
)
|
||||||
|
async def test_volume_relative_media_player_intent(
|
||||||
|
hass: HomeAssistant, direction: str, volume_change: float, volume_change_int: int
|
||||||
|
) -> None:
|
||||||
|
"""Test relative volume intents for media players."""
|
||||||
|
assert await async_setup_component(hass, DOMAIN, {})
|
||||||
|
await media_player_intent.async_setup_intents(hass)
|
||||||
|
|
||||||
|
component: EntityComponent[MediaPlayerEntity] = hass.data[DOMAIN]
|
||||||
|
|
||||||
|
default_volume = 0.5
|
||||||
|
|
||||||
|
class VolumeTestMediaPlayer(MediaPlayerEntity):
|
||||||
|
_attr_supported_features = MediaPlayerEntityFeature.VOLUME_SET
|
||||||
|
_attr_volume_level = default_volume
|
||||||
|
_attr_volume_step = 0.1
|
||||||
|
_attr_state = MediaPlayerState.IDLE
|
||||||
|
|
||||||
|
async def async_set_volume_level(self, volume):
|
||||||
|
self._attr_volume_level = volume
|
||||||
|
|
||||||
|
idle_entity = VolumeTestMediaPlayer()
|
||||||
|
idle_entity.hass = hass
|
||||||
|
idle_entity.platform = MockEntityPlatform(hass)
|
||||||
|
idle_entity.entity_id = f"{DOMAIN}.idle_media_player"
|
||||||
|
await component.async_add_entities([idle_entity])
|
||||||
|
|
||||||
|
hass.states.async_set(
|
||||||
|
idle_entity.entity_id,
|
||||||
|
STATE_IDLE,
|
||||||
|
attributes={
|
||||||
|
ATTR_SUPPORTED_FEATURES: MediaPlayerEntityFeature.VOLUME_SET,
|
||||||
|
ATTR_FRIENDLY_NAME: "Idle Media Player",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
idle_expected_volume = default_volume
|
||||||
|
|
||||||
|
# Only 1 media player is present, so it's targeted even though its idle
|
||||||
|
assert idle_entity.volume_level is not None
|
||||||
|
assert math.isclose(idle_entity.volume_level, idle_expected_volume)
|
||||||
|
response = await intent.async_handle(
|
||||||
|
hass,
|
||||||
|
"test",
|
||||||
|
media_player_intent.INTENT_SET_VOLUME_RELATIVE,
|
||||||
|
{"volume_step": {"value": direction}},
|
||||||
|
)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert response.response_type == intent.IntentResponseType.ACTION_DONE
|
||||||
|
idle_expected_volume += volume_change
|
||||||
|
assert math.isclose(idle_entity.volume_level, idle_expected_volume)
|
||||||
|
|
||||||
|
# Multiple media players (playing one should be targeted)
|
||||||
|
playing_entity = VolumeTestMediaPlayer()
|
||||||
|
playing_entity.hass = hass
|
||||||
|
playing_entity.platform = MockEntityPlatform(hass)
|
||||||
|
playing_entity.entity_id = f"{DOMAIN}.playing_media_player"
|
||||||
|
await component.async_add_entities([playing_entity])
|
||||||
|
|
||||||
|
hass.states.async_set(
|
||||||
|
playing_entity.entity_id,
|
||||||
|
STATE_PLAYING,
|
||||||
|
attributes={
|
||||||
|
ATTR_SUPPORTED_FEATURES: MediaPlayerEntityFeature.VOLUME_SET,
|
||||||
|
ATTR_FRIENDLY_NAME: "Playing Media Player",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
playing_expected_volume = default_volume
|
||||||
|
assert playing_entity.volume_level is not None
|
||||||
|
assert math.isclose(playing_entity.volume_level, playing_expected_volume)
|
||||||
|
response = await intent.async_handle(
|
||||||
|
hass,
|
||||||
|
"test",
|
||||||
|
media_player_intent.INTENT_SET_VOLUME_RELATIVE,
|
||||||
|
{"volume_step": {"value": direction}},
|
||||||
|
)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert response.response_type == intent.IntentResponseType.ACTION_DONE
|
||||||
|
playing_expected_volume += volume_change
|
||||||
|
assert math.isclose(idle_entity.volume_level, idle_expected_volume)
|
||||||
|
assert math.isclose(playing_entity.volume_level, playing_expected_volume)
|
||||||
|
|
||||||
|
# We can still target by name even if the media player is idle
|
||||||
|
response = await intent.async_handle(
|
||||||
|
hass,
|
||||||
|
"test",
|
||||||
|
media_player_intent.INTENT_SET_VOLUME_RELATIVE,
|
||||||
|
{"volume_step": {"value": direction}, "name": {"value": "Idle media player"}},
|
||||||
|
)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert response.response_type == intent.IntentResponseType.ACTION_DONE
|
||||||
|
idle_expected_volume += volume_change
|
||||||
|
assert math.isclose(idle_entity.volume_level, idle_expected_volume)
|
||||||
|
assert math.isclose(playing_entity.volume_level, playing_expected_volume)
|
||||||
|
|
||||||
|
# Set relative volume by percent
|
||||||
|
response = await intent.async_handle(
|
||||||
|
hass,
|
||||||
|
"test",
|
||||||
|
media_player_intent.INTENT_SET_VOLUME_RELATIVE,
|
||||||
|
{"volume_step": {"value": volume_change_int}},
|
||||||
|
)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert response.response_type == intent.IntentResponseType.ACTION_DONE
|
||||||
|
playing_expected_volume += volume_change_int / 100
|
||||||
|
assert math.isclose(idle_entity.volume_level, idle_expected_volume)
|
||||||
|
assert math.isclose(playing_entity.volume_level, playing_expected_volume)
|
||||||
|
|
||||||
|
# Test error in method
|
||||||
|
with (
|
||||||
|
patch.object(
|
||||||
|
playing_entity, "async_volume_up", side_effect=RuntimeError("boom!")
|
||||||
|
),
|
||||||
|
pytest.raises(intent.IntentError),
|
||||||
|
):
|
||||||
|
await intent.async_handle(
|
||||||
|
hass,
|
||||||
|
"test",
|
||||||
|
media_player_intent.INTENT_SET_VOLUME_RELATIVE,
|
||||||
|
{"volume_step": {"value": "up"}},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Multiple idle media players should not match
|
||||||
|
hass.states.async_set(
|
||||||
|
playing_entity.entity_id,
|
||||||
|
STATE_IDLE,
|
||||||
|
attributes={ATTR_SUPPORTED_FEATURES: MediaPlayerEntityFeature.VOLUME_SET},
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(intent.MatchFailedError):
|
||||||
|
await intent.async_handle(
|
||||||
|
hass,
|
||||||
|
"test",
|
||||||
|
media_player_intent.INTENT_SET_VOLUME_RELATIVE,
|
||||||
|
{"volume_step": {"value": direction}},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Test feature not supported
|
||||||
|
for entity_id in (idle_entity.entity_id, playing_entity.entity_id):
|
||||||
|
hass.states.async_set(
|
||||||
|
entity_id,
|
||||||
|
STATE_PLAYING,
|
||||||
|
attributes={ATTR_SUPPORTED_FEATURES: MediaPlayerEntityFeature(0)},
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(intent.MatchFailedError):
|
||||||
|
await intent.async_handle(
|
||||||
|
hass,
|
||||||
|
"test",
|
||||||
|
media_player_intent.INTENT_SET_VOLUME_RELATIVE,
|
||||||
|
{"volume_step": {"value": direction}},
|
||||||
|
)
|
||||||
|
Reference in New Issue
Block a user