Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
49ae859
Add media_player get_groupable_players service
dan-s-github Jan 12, 2026
913a03f
Merge branch 'home-assistant:dev' into feature/get-groupable-players-…
dan-s-github Jan 14, 2026
cc775d1
Merge branch 'home-assistant:dev' into feature/get-groupable-players-…
dan-s-github Jan 15, 2026
d559277
Merge branch 'home-assistant:dev' into feature/get-groupable-players-…
dan-s-github Jan 17, 2026
735c1dc
add new service tests
dan-s-github Jan 17, 2026
56f8f44
Merge branch 'home-assistant:dev' into feature/get-groupable-players-…
dan-s-github Jan 20, 2026
33ecd2d
Merge branch 'home-assistant:dev' into feature/get-groupable-players-…
dan-s-github Jan 20, 2026
70ae9ae
address review comments
dan-s-github Jan 20, 2026
e90b41d
Update tests/components/media_player/test_init.py
dan-s-github Jan 21, 2026
412ee08
Update homeassistant/components/media_player/strings.json
dan-s-github Jan 21, 2026
bb3cf92
Update homeassistant/components/media_player/services.yaml
dan-s-github Jan 21, 2026
e97543a
Update homeassistant/components/media_player/const.py
dan-s-github Jan 21, 2026
36fb036
Update homeassistant/components/media_player/__init__.py
dan-s-github Jan 21, 2026
b92f100
Update homeassistant/components/media_player/__init__.py
dan-s-github Jan 21, 2026
9656740
Initial plan
Copilot Jan 21, 2026
67e3b4a
Remove misleading multiplatform test that bypasses real implementation
Copilot Jan 21, 2026
4b8f5b8
Add test for multi-platform grouping via method override
Copilot Jan 21, 2026
181b660
fix const renaming
dan-s-github Jan 21, 2026
74f3191
Merge branch 'feature/get-groupable-players-2342' into copilot/sub-pr-2
dan-s-github Jan 21, 2026
fe97877
Merge pull request #3 from dan-s-github/copilot/sub-pr-2
dan-s-github Jan 21, 2026
14dfe4e
Merge branch 'dan-dev' into feature/get-groupable-players-2342
dan-s-github Jan 22, 2026
d0c1cf2
code review fixes
dan-s-github Jan 22, 2026
b1db93e
fix test warnings
dan-s-github Jan 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 41 additions & 2 deletions homeassistant/components/media_player/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@
STATE_STANDBY,
)
from homeassistant.core import HomeAssistant, SupportsResponse
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.entity import Entity, EntityDescription
from homeassistant.helpers.entity_component import EntityComponent
Expand Down Expand Up @@ -108,6 +108,7 @@
REPEAT_MODES,
SERVICE_BROWSE_MEDIA,
SERVICE_CLEAR_PLAYLIST,
SERVICE_GET_GROUPABLE_PLAYERS,
SERVICE_JOIN,
SERVICE_PLAY_MEDIA,
SERVICE_SEARCH_MEDIA,
Expand Down Expand Up @@ -466,13 +467,19 @@ def _rewrite_enqueue(value: dict[str, Any]) -> dict[str, Any]:
component.async_register_entity_service(
SERVICE_UNJOIN, None, "async_unjoin_player", [MediaPlayerEntityFeature.GROUPING]
)

component.async_register_entity_service(
SERVICE_REPEAT_SET,
{vol.Required(ATTR_MEDIA_REPEAT): vol.Coerce(RepeatMode)},
"async_set_repeat",
[MediaPlayerEntityFeature.REPEAT_SET],
)
component.async_register_entity_service(
SERVICE_GET_GROUPABLE_PLAYERS,
None,
"async_get_groupable_players",
[MediaPlayerEntityFeature.GROUPING],
supports_response=SupportsResponse.ONLY,
)

return True

Expand Down Expand Up @@ -1193,6 +1200,38 @@ async def async_unjoin_player(self) -> None:
"""Remove this player from any group."""
await self.hass.async_add_executor_job(self.unjoin_player)

async def async_get_groupable_players(self) -> dict[str, Any]:
"""Return a dictionary with a list of players that can be grouped with this player."""

component = self.hass.data.get(DATA_COMPONENT)
if component is None:
return {"result": []}

entity_registry = er.async_get(self.hass)

# Get the integration platform of the current entity
if not (entry := entity_registry.async_get(self.entity_id)):
return {"result": []}

current_platform = entry.platform

# Build a set of entity_ids for the current platform for faster lookup
platform_entity_ids = {
entity_id
for entity_id, registry_entry in entity_registry.entities.items()
if registry_entry.platform == current_platform
}
# Return only players that support grouping, excluding the calling entity
result = [
entity.entity_id
for entity in component.entities
if entity.entity_id != self.entity_id
and entity.entity_id in platform_entity_ids
and MediaPlayerEntityFeature.GROUPING in entity.supported_features
]

return {"result": result}

async def _async_fetch_image_from_cache(
self, url: str
) -> tuple[bytes | None, str | None]:
Expand Down
1 change: 1 addition & 0 deletions homeassistant/components/media_player/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ class MediaType(StrEnum):
SERVICE_SELECT_SOUND_MODE = "select_sound_mode"
SERVICE_SELECT_SOURCE = "select_source"
SERVICE_UNJOIN = "unjoin"
SERVICE_GET_GROUPABLE_PLAYERS = "get_groupable_players"


class RepeatMode(StrEnum):
Expand Down
3 changes: 3 additions & 0 deletions homeassistant/components/media_player/icons.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@
"clear_playlist": {
"service": "mdi:playlist-remove"
},
"get_groupable_players": {
"service": "mdi:speaker-multiple"
},
"join": {
"service": "mdi:group"
},
Expand Down
7 changes: 7 additions & 0 deletions homeassistant/components/media_player/services.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -289,3 +289,10 @@ unjoin:
domain: media_player
supported_features:
- media_player.MediaPlayerEntityFeature.GROUPING

get_groupable_players:
target:
entity:
domain: media_player
supported_features:
- media_player.MediaPlayerEntityFeature.GROUPING
4 changes: 4 additions & 0 deletions homeassistant/components/media_player/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,10 @@
"description": "Removes all items from the playlist.",
"name": "Clear playlist"
},
"get_groupable_players": {
"description": "Gets a list of media players that can be grouped together for synchronous playback. Only works on supported multiroom audio systems.",
"name": "Get groupable players"
},
"join": {
"description": "Groups media players together for synchronous playback. Only works on supported multiroom audio systems.",
"fields": {
Expand Down
189 changes: 189 additions & 0 deletions tests/components/media_player/test_init.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Test the base functions of the media player."""

from http import HTTPStatus
from typing import Any
from unittest.mock import patch

import pytest
Expand All @@ -21,11 +22,13 @@
)
from homeassistant.components.media_player.const import (
SERVICE_BROWSE_MEDIA,
SERVICE_GET_GROUPABLE_PLAYERS,
SERVICE_SEARCH_MEDIA,
)
from homeassistant.components.websocket_api import TYPE_RESULT
from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceNotSupported
from homeassistant.setup import async_setup_component

from tests.common import MockEntityPlatform
Expand Down Expand Up @@ -634,3 +637,189 @@ async def test_play_media_via_selector(hass: HomeAssistant) -> None:
},
blocking=True,
)


async def test_get_groupable_players_service(hass: HomeAssistant) -> None:
"""Test get_groupable_players service returns correct response structure."""
# Simple test to verify service exists and returns the expected structure.
# The filtering logic (platform matching, feature support) is tested
# in test_get_groupable_players_same_platform_only and
# test_get_groupable_players_multiplatform_override below.
await async_setup_component(
hass, "media_player", {"media_player": {"platform": "demo"}}
)
await hass.async_block_till_done()

result = await hass.services.async_call(
"media_player",
SERVICE_GET_GROUPABLE_PLAYERS,
{ATTR_ENTITY_ID: "media_player.walkman"},
blocking=True,
return_response=True,
)

# Verify the service returns proper response structure
assert result is not None
assert "media_player.walkman" in result
assert isinstance(result["media_player.walkman"], dict)
assert "result" in result["media_player.walkman"]
assert isinstance(result["media_player.walkman"]["result"], list)


async def test_get_groupable_players_entity_without_feature(
hass: HomeAssistant,
) -> None:
"""Test that get_groupable_players only works for entities with grouping support."""
await async_setup_component(
hass, "media_player", {"media_player": {"platform": "demo"}}
)
await hass.async_block_till_done()

# Try to call service on an entity without grouping support (YouTube player)
with pytest.raises(
ServiceNotSupported
): # Should raise since bedroom doesn't support grouping
await hass.services.async_call(
"media_player",
SERVICE_GET_GROUPABLE_PLAYERS,
{ATTR_ENTITY_ID: "media_player.bedroom"},
blocking=True,
return_response=True,
)


async def test_get_groupable_players_same_platform_only(hass: HomeAssistant) -> None:
"""Test that get_groupable_players returns only entities from the same platform.

The default implementation returns only entities from the same platform
as the calling entity.
"""

class TestMediaPlayer(MediaPlayerEntity):
"""Test media player with grouping support."""

_attr_supported_features = media_player.MediaPlayerEntityFeature.GROUPING
_attr_has_entity_name = True

def __init__(self, unique_id: str, name: str) -> None:
"""Initialize the test entity."""
self._attr_unique_id = unique_id
self._attr_name = name

# Setup media_player component first
await async_setup_component(hass, "media_player", {})
await hass.async_block_till_done()

# Create test entities on the same platform
player1 = TestMediaPlayer("test_player_1", "Player 1")
player1.hass = hass
player1.platform = MockEntityPlatform(hass, platform_name="test_platform")
player1.entity_id = "media_player.player_1"

player2 = TestMediaPlayer("test_player_2", "Player 2")
player2.hass = hass
player2.platform = MockEntityPlatform(hass, platform_name="test_platform")
player2.entity_id = "media_player.player_2"

player3 = TestMediaPlayer("test_player_3", "Player 3")
player3.hass = hass
player3.platform = MockEntityPlatform(hass, platform_name="test_platform")
player3.entity_id = "media_player.player_3"

# Add entities to the component
component = hass.data.get("entity_components", {}).get("media_player")
assert component
await component.async_add_entities([player1, player2, player3])
await hass.async_block_till_done()

# Get groupable players for player1
result = await hass.services.async_call(
"media_player",
SERVICE_GET_GROUPABLE_PLAYERS,
{ATTR_ENTITY_ID: "media_player.player_1"},
blocking=True,
return_response=True,
)

assert result is not None
assert isinstance(result["media_player.player_1"], dict)
groupable = result["media_player.player_1"]["result"]
assert isinstance(groupable, list)

# The calling entity should not appear in its own groupable list
assert "media_player.player_1" not in groupable

# Should return the other two players from the same platform
assert "media_player.player_2" in groupable
assert "media_player.player_3" in groupable
assert len(groupable) == 2


async def test_get_groupable_players_multiplatform_override(
hass: HomeAssistant,
) -> None:
"""Test that integrations can override async_get_groupable_players for multi-platform support.

This demonstrates how an integration can override the default implementation
to return entities from multiple platforms that it can group together.
"""

class MultiPlatformMediaPlayer(MediaPlayerEntity):
"""Test media player that supports grouping across platforms."""

_attr_supported_features = media_player.MediaPlayerEntityFeature.GROUPING

def __init__(self, entity_id: str, name: str) -> None:
"""Initialize the test entity."""
self.entity_id = entity_id
self._attr_name = name

async def async_get_groupable_players(self) -> dict[str, Any]:
"""Override to return multi-platform groupable players."""
# Integration-specific logic that can determine which players
# from different platforms can be grouped together
return {
"result": [
"media_player.spotify_player",
"media_player.sonos_living_room",
"media_player.chromecast_kitchen",
]
}

# Setup media_player component first
await async_setup_component(hass, "media_player", {})
await hass.async_block_till_done()

# Create and register the custom entity
entity = MultiPlatformMediaPlayer("media_player.test_player", "Test Player")
entity.hass = hass
entity.platform = MockEntityPlatform(hass)

# Manually add entity to the entity component
component = hass.data.get("entity_components", {}).get("media_player")
if component:
await component.async_add_entities([entity])
else:
# Fallback: add directly to state machine
await entity.async_internal_added_to_hass()
await hass.async_block_till_done()

# Call the service
result = await hass.services.async_call(
"media_player",
SERVICE_GET_GROUPABLE_PLAYERS,
{ATTR_ENTITY_ID: "media_player.test_player"},
blocking=True,
return_response=True,
)

# Verify the overridden implementation's results are returned
assert result is not None
assert "media_player.test_player" in result
assert isinstance(result["media_player.test_player"], dict)
groupable_players = result["media_player.test_player"]["result"]
assert isinstance(groupable_players, list)
assert len(groupable_players) == 3
assert "media_player.spotify_player" in groupable_players
assert "media_player.sonos_living_room" in groupable_players
assert "media_player.chromecast_kitchen" in groupable_players
Loading