Add Get Queue HEOS entity service (#141150)

This commit is contained in:
Andrew Sayre 2025-03-25 16:55:44 -05:00 committed by GitHub
parent f3bcb96b41
commit ab709aeb46
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 120 additions and 8 deletions

View File

@ -4,6 +4,7 @@ ATTR_PASSWORD = "password"
ATTR_USERNAME = "username" ATTR_USERNAME = "username"
DOMAIN = "heos" DOMAIN = "heos"
ENTRY_TITLE = "HEOS System" ENTRY_TITLE = "HEOS System"
SERVICE_GET_QUEUE = "get_queue"
SERVICE_GROUP_VOLUME_SET = "group_volume_set" SERVICE_GROUP_VOLUME_SET = "group_volume_set"
SERVICE_GROUP_VOLUME_DOWN = "group_volume_down" SERVICE_GROUP_VOLUME_DOWN = "group_volume_down"
SERVICE_GROUP_VOLUME_UP = "group_volume_up" SERVICE_GROUP_VOLUME_UP = "group_volume_up"

View File

@ -1,5 +1,8 @@
{ {
"services": { "services": {
"get_queue": {
"service": "mdi:playlist-music"
},
"group_volume_set": { "group_volume_set": {
"service": "mdi:volume-medium" "service": "mdi:volume-medium"
}, },

View File

@ -4,6 +4,7 @@ from __future__ import annotations
from collections.abc import Awaitable, Callable, Coroutine, Sequence from collections.abc import Awaitable, Callable, Coroutine, Sequence
from contextlib import suppress from contextlib import suppress
import dataclasses
from datetime import datetime from datetime import datetime
from functools import reduce, wraps from functools import reduce, wraps
import logging import logging
@ -42,7 +43,12 @@ from homeassistant.components.media_player import (
) )
from homeassistant.components.media_source import BrowseMediaSource from homeassistant.components.media_source import BrowseMediaSource
from homeassistant.const import Platform from homeassistant.const import Platform
from homeassistant.core import HomeAssistant, callback from homeassistant.core import (
HomeAssistant,
ServiceResponse,
SupportsResponse,
callback,
)
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import ( from homeassistant.helpers import (
config_validation as cv, config_validation as cv,
@ -56,6 +62,7 @@ from homeassistant.util.dt import utcnow
from .const import ( from .const import (
DOMAIN as HEOS_DOMAIN, DOMAIN as HEOS_DOMAIN,
SERVICE_GET_QUEUE,
SERVICE_GROUP_VOLUME_DOWN, SERVICE_GROUP_VOLUME_DOWN,
SERVICE_GROUP_VOLUME_SET, SERVICE_GROUP_VOLUME_SET,
SERVICE_GROUP_VOLUME_UP, SERVICE_GROUP_VOLUME_UP,
@ -132,6 +139,12 @@ async def async_setup_entry(
"""Add media players for a config entry.""" """Add media players for a config entry."""
# Register custom entity services # Register custom entity services
platform = entity_platform.async_get_current_platform() platform = entity_platform.async_get_current_platform()
platform.async_register_entity_service(
SERVICE_GET_QUEUE,
None,
"async_get_queue",
supports_response=SupportsResponse.ONLY,
)
platform.async_register_entity_service( platform.async_register_entity_service(
SERVICE_GROUP_VOLUME_SET, SERVICE_GROUP_VOLUME_SET,
{vol.Required(ATTR_MEDIA_VOLUME_LEVEL): cv.small_float}, {vol.Required(ATTR_MEDIA_VOLUME_LEVEL): cv.small_float},
@ -155,20 +168,20 @@ async def async_setup_entry(
add_entities_callback(list(coordinator.heos.players.values())) add_entities_callback(list(coordinator.heos.players.values()))
type _FuncType[**_P] = Callable[_P, Awaitable[Any]] type _FuncType[**_P, _R] = Callable[_P, Awaitable[_R]]
type _ReturnFuncType[**_P] = Callable[_P, Coroutine[Any, Any, None]] type _ReturnFuncType[**_P, _R] = Callable[_P, Coroutine[Any, Any, _R]]
def catch_action_error[**_P]( def catch_action_error[**_P, _R](
action: str, action: str,
) -> Callable[[_FuncType[_P]], _ReturnFuncType[_P]]: ) -> Callable[[_FuncType[_P, _R]], _ReturnFuncType[_P, _R]]:
"""Return decorator that catches errors and raises HomeAssistantError.""" """Return decorator that catches errors and raises HomeAssistantError."""
def decorator(func: _FuncType[_P]) -> _ReturnFuncType[_P]: def decorator(func: _FuncType[_P, _R]) -> _ReturnFuncType[_P, _R]:
@wraps(func) @wraps(func)
async def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> None: async def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _R:
try: try:
await func(*args, **kwargs) return await func(*args, **kwargs)
except (HeosError, ValueError) as ex: except (HeosError, ValueError) as ex:
raise HomeAssistantError( raise HomeAssistantError(
translation_domain=HEOS_DOMAIN, translation_domain=HEOS_DOMAIN,
@ -268,6 +281,12 @@ class HeosMediaPlayer(CoordinatorEntity[HeosCoordinator], MediaPlayerEntity):
self.async_on_remove(self._player.add_on_player_event(self._player_update)) self.async_on_remove(self._player.add_on_player_event(self._player_update))
await super().async_added_to_hass() await super().async_added_to_hass()
@catch_action_error("get queue")
async def async_get_queue(self) -> ServiceResponse:
"""Get the queue for the current player."""
queue = await self._player.get_queue()
return {"queue": [dataclasses.asdict(item) for item in queue]}
@catch_action_error("clear playlist") @catch_action_error("clear playlist")
async def async_clear_playlist(self) -> None: async def async_clear_playlist(self) -> None:
"""Clear players playlist.""" """Clear players playlist."""

View File

@ -1,3 +1,9 @@
get_queue:
target:
entity:
integration: heos
domain: media_player
group_volume_set: group_volume_set:
target: target:
entity: entity:

View File

@ -86,6 +86,10 @@
} }
} }
}, },
"get_queue": {
"name": "Get queue",
"description": "Retrieves the queue of the media player."
},
"group_volume_down": { "group_volume_down": {
"name": "Turn down group volume", "name": "Turn down group volume",
"description": "Turns down the group volume." "description": "Turns down the group volume."

View File

@ -37,6 +37,7 @@ class MockHeos(Heos):
self.play_preset_station: AsyncMock = AsyncMock() self.play_preset_station: AsyncMock = AsyncMock()
self.play_url: AsyncMock = AsyncMock() self.play_url: AsyncMock = AsyncMock()
self.player_clear_queue: AsyncMock = AsyncMock() self.player_clear_queue: AsyncMock = AsyncMock()
self.player_get_queue: AsyncMock = AsyncMock()
self.player_get_quick_selects: AsyncMock = AsyncMock() self.player_get_quick_selects: AsyncMock = AsyncMock()
self.player_play_next: AsyncMock = AsyncMock() self.player_play_next: AsyncMock = AsyncMock()
self.player_play_previous: AsyncMock = AsyncMock() self.player_play_previous: AsyncMock = AsyncMock()

View File

@ -20,6 +20,7 @@ from pyheos import (
NetworkType, NetworkType,
PlayerUpdateResult, PlayerUpdateResult,
PlayState, PlayState,
QueueItem,
RepeatType, RepeatType,
const, const,
) )
@ -359,3 +360,28 @@ def change_data_fixture() -> PlayerUpdateResult:
def change_data_mapped_ids_fixture() -> PlayerUpdateResult: def change_data_mapped_ids_fixture() -> PlayerUpdateResult:
"""Create player change data for testing.""" """Create player change data for testing."""
return PlayerUpdateResult(updated_player_ids={1: 101}) return PlayerUpdateResult(updated_player_ids={1: 101})
@pytest.fixture(name="queue")
def queue_fixture() -> list[QueueItem]:
"""Create a queue fixture."""
return [
QueueItem(
queue_id=1,
song="Espresso",
album="Espresso",
artist="Sabrina Carpenter",
image_url="http://resources.wimpmusic.com/images/e4f2d75f/a69e/4b8a/b800/e18546b1ad4c/640x640.jpg",
media_id="356276483",
album_id="356276481",
),
QueueItem(
queue_id=2,
song="A Bar Song (Tipsy)",
album="A Bar Song (Tipsy)",
artist="Shaboozey",
image_url="http://resources.wimpmusic.com/images/d05b8da3/4fae/45ff/ac1b/7ab7caab3523/640x640.jpg",
media_id="354365598",
album_id="354365596",
),
]

View File

@ -159,6 +159,32 @@
'title': 'Music Sources', 'title': 'Music Sources',
}) })
# --- # ---
# name: test_get_queue
dict({
'media_player.test_player': dict({
'queue': list([
dict({
'album': 'Espresso',
'album_id': '356276481',
'artist': 'Sabrina Carpenter',
'image_url': 'http://resources.wimpmusic.com/images/e4f2d75f/a69e/4b8a/b800/e18546b1ad4c/640x640.jpg',
'media_id': '356276483',
'queue_id': 1,
'song': 'Espresso',
}),
dict({
'album': 'A Bar Song (Tipsy)',
'album_id': '354365596',
'artist': 'Shaboozey',
'image_url': 'http://resources.wimpmusic.com/images/d05b8da3/4fae/45ff/ac1b/7ab7caab3523/640x640.jpg',
'media_id': '354365598',
'queue_id': 2,
'song': 'A Bar Song (Tipsy)',
}),
]),
}),
})
# ---
# name: test_state_attributes # name: test_state_attributes
StateSnapshot({ StateSnapshot({
'attributes': ReadOnlyDict({ 'attributes': ReadOnlyDict({

View File

@ -15,6 +15,7 @@ from pyheos import (
MediaType as HeosMediaType, MediaType as HeosMediaType,
PlayerUpdateResult, PlayerUpdateResult,
PlayState, PlayState,
QueueItem,
RepeatType, RepeatType,
SignalHeosEvent, SignalHeosEvent,
SignalType, SignalType,
@ -27,6 +28,7 @@ from syrupy.filters import props
from homeassistant.components.heos.const import ( from homeassistant.components.heos.const import (
DOMAIN, DOMAIN,
SERVICE_GET_QUEUE,
SERVICE_GROUP_VOLUME_DOWN, SERVICE_GROUP_VOLUME_DOWN,
SERVICE_GROUP_VOLUME_SET, SERVICE_GROUP_VOLUME_SET,
SERVICE_GROUP_VOLUME_UP, SERVICE_GROUP_VOLUME_UP,
@ -1696,3 +1698,27 @@ async def test_media_player_group_fails_wrong_integration(
blocking=True, blocking=True,
) )
controller.set_group.assert_not_called() controller.set_group.assert_not_called()
async def test_get_queue(
hass: HomeAssistant,
config_entry: MockConfigEntry,
controller: MockHeos,
queue: list[QueueItem],
snapshot: SnapshotAssertion,
) -> None:
"""Test the get queue service."""
config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry.entry_id)
controller.player_get_queue.return_value = queue
response = await hass.services.async_call(
DOMAIN,
SERVICE_GET_QUEUE,
{
ATTR_ENTITY_ID: "media_player.test_player",
},
blocking=True,
return_response=True,
)
controller.player_get_queue.assert_called_once_with(1, None, None)
assert response == snapshot