diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index ea9dd0adcfdd2..2288c6e5c4c89 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -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 @@ -108,6 +108,7 @@ REPEAT_MODES, SERVICE_BROWSE_MEDIA, SERVICE_CLEAR_PLAYLIST, + SERVICE_GET_GROUPABLE_PLAYERS, SERVICE_JOIN, SERVICE_PLAY_MEDIA, SERVICE_SEARCH_MEDIA, @@ -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 @@ -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]: diff --git a/homeassistant/components/media_player/const.py b/homeassistant/components/media_player/const.py index 4415b9ab7d176..716f8e8f9ce9e 100644 --- a/homeassistant/components/media_player/const.py +++ b/homeassistant/components/media_player/const.py @@ -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): diff --git a/homeassistant/components/media_player/icons.json b/homeassistant/components/media_player/icons.json index 9468a79420162..46f2d0c7406f9 100644 --- a/homeassistant/components/media_player/icons.json +++ b/homeassistant/components/media_player/icons.json @@ -38,6 +38,9 @@ "clear_playlist": { "service": "mdi:playlist-remove" }, + "get_groupable_players": { + "service": "mdi:speaker-multiple" + }, "join": { "service": "mdi:group" }, diff --git a/homeassistant/components/media_player/services.yaml b/homeassistant/components/media_player/services.yaml index 26a2624a61c41..d061e206241f1 100644 --- a/homeassistant/components/media_player/services.yaml +++ b/homeassistant/components/media_player/services.yaml @@ -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 diff --git a/homeassistant/components/media_player/strings.json b/homeassistant/components/media_player/strings.json index 7b75b73f12364..f420bb7aba7d5 100644 --- a/homeassistant/components/media_player/strings.json +++ b/homeassistant/components/media_player/strings.json @@ -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": { diff --git a/tests/components/media_player/test_init.py b/tests/components/media_player/test_init.py index 2d472d0595b3a..aab07a5895ed5 100644 --- a/tests/components/media_player/test_init.py +++ b/tests/components/media_player/test_init.py @@ -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 @@ -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 @@ -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