Add volume up/down intents for media players (#150443)

This commit is contained in:
Michael Hansen
2025-08-13 09:35:37 -05:00
committed by GitHub
parent eb4b75a9a7
commit 721f9a40d8
2 changed files with 300 additions and 8 deletions

View File

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

View File

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