mirror of
https://github.com/home-assistant/core.git
synced 2025-08-31 18:31:35 +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."""
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Iterable
|
||||
from dataclasses import dataclass, field
|
||||
import logging
|
||||
@@ -14,21 +15,21 @@ from homeassistant.const import (
|
||||
SERVICE_MEDIA_PLAY,
|
||||
SERVICE_MEDIA_PREVIOUS_TRACK,
|
||||
SERVICE_VOLUME_SET,
|
||||
STATE_PLAYING,
|
||||
)
|
||||
from homeassistant.core import Context, HomeAssistant, State
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
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,
|
||||
DOMAIN,
|
||||
SERVICE_PLAY_MEDIA,
|
||||
SERVICE_SEARCH_MEDIA,
|
||||
MediaPlayerDeviceClass,
|
||||
SearchMedia,
|
||||
)
|
||||
from .const import (
|
||||
ATTR_MEDIA_FILTER_CLASSES,
|
||||
MediaClass,
|
||||
MediaPlayerEntityFeature,
|
||||
MediaPlayerState,
|
||||
@@ -39,6 +40,7 @@ INTENT_MEDIA_UNPAUSE = "HassMediaUnpause"
|
||||
INTENT_MEDIA_NEXT = "HassMediaNext"
|
||||
INTENT_MEDIA_PREVIOUS = "HassMediaPrevious"
|
||||
INTENT_SET_VOLUME = "HassSetVolume"
|
||||
INTENT_SET_VOLUME_RELATIVE = "HassSetVolumeRelative"
|
||||
INTENT_MEDIA_SEARCH_AND_PLAY = "HassMediaSearchAndPlay"
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -127,6 +129,7 @@ async def async_setup_intents(hass: HomeAssistant) -> None:
|
||||
device_classes={MediaPlayerDeviceClass},
|
||||
),
|
||||
)
|
||||
intent.async_register(hass, MediaSetVolumeRelativeHandler())
|
||||
intent.async_register(hass, MediaSearchAndPlayHandler())
|
||||
|
||||
|
||||
@@ -354,3 +357,120 @@ class MediaSearchAndPlayHandler(intent.IntentHandler):
|
||||
response.async_set_speech_slots({"media": first_result.as_dict()})
|
||||
response.response_type = intent.IntentResponseType.ACTION_DONE
|
||||
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."""
|
||||
|
||||
import math
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.media_player import (
|
||||
@@ -13,12 +16,17 @@ from homeassistant.components.media_player import (
|
||||
SERVICE_VOLUME_SET,
|
||||
BrowseMedia,
|
||||
MediaClass,
|
||||
MediaPlayerEntity,
|
||||
MediaType,
|
||||
SearchMedia,
|
||||
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 (
|
||||
ATTR_FRIENDLY_NAME,
|
||||
ATTR_SUPPORTED_FEATURES,
|
||||
STATE_IDLE,
|
||||
STATE_PAUSED,
|
||||
@@ -32,8 +40,10 @@ from homeassistant.helpers import (
|
||||
floor_registry as fr,
|
||||
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:
|
||||
@@ -873,3 +883,165 @@ async def test_search_and_play_media_player_intent_with_media_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