From 49ae85951a1887ddb3a5f4cb6e5fd995c7f9a13b Mon Sep 17 00:00:00 2001 From: dan-s-github Date: Mon, 12 Jan 2026 08:12:54 +0000 Subject: [PATCH 01/15] Add media_player get_groupable_players service --- .../components/media_player/__init__.py | 44 ++++++++++++++++++- .../components/media_player/const.py | 1 + .../components/media_player/icons.json | 3 ++ .../components/media_player/services.yaml | 9 +++- .../components/media_player/strings.json | 4 ++ 5 files changed, 58 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index ea9dd0adcfdd2..dd583d169d505 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_MEMBERS, 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_MEMBERS, + None, + "async_get_groupable_players", + [MediaPlayerEntityFeature.GROUPING], + supports_response=SupportsResponse.ONLY, + ) return True @@ -1193,6 +1200,39 @@ async def async_unjoin_player(self) -> None: """Remove this player from any group.""" await self.hass.async_add_executor_job(self.unjoin_player) + def get_groupable_players(self) -> dict[str, Any]: + """Return 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 + + # Return only players from the same integration platform + result = [ + entity_id + for entity_id in [ + entity.entity_id + for entity in component.entities + if isinstance(entity, MediaPlayerEntity) + ] + if (registry_entry := entity_registry.async_get(entity_id)) + and registry_entry.platform == current_platform + ] + + return {"result": result} + + async def async_get_groupable_players(self) -> dict[str, Any]: + """Return a list of players that can be grouped with this player.""" + return await self.hass.async_add_executor_job(self.get_groupable_players) + 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..8d1bcd99b8cd0 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_MEMBERS = "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..10e4dba21ada4 100644 --- a/homeassistant/components/media_player/services.yaml +++ b/homeassistant/components/media_player/services.yaml @@ -120,7 +120,7 @@ media_seek: selector: number: min: 0 - max: 9223372036854775807 + max: 9223372036854776000 step: 0.01 mode: box @@ -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..c0abfcbda3bc5 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 synchronized 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": { From 735c1dc7446ba35348bc6d6b06d233edfd489bad Mon Sep 17 00:00:00 2001 From: dan-s-github Date: Sat, 17 Jan 2026 22:09:56 +0000 Subject: [PATCH 02/15] add new service tests --- tests/components/media_player/test_init.py | 123 +++++++++++++++++++++ 1 file changed, 123 insertions(+) diff --git a/tests/components/media_player/test_init.py b/tests/components/media_player/test_init.py index 2d472d0595b3a..f3e9a95f50352 100644 --- a/tests/components/media_player/test_init.py +++ b/tests/components/media_player/test_init.py @@ -21,11 +21,13 @@ ) from homeassistant.components.media_player.const import ( SERVICE_BROWSE_MEDIA, + SERVICE_GET_GROUPABLE_MEMBERS, 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 +636,124 @@ 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 call returns groupable players.""" + # Use DemoMusicPlayer which has GROUPING support + 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_MEMBERS, + {ATTR_ENTITY_ID: "media_player.walkman"}, # Music player with grouping support + blocking=True, + return_response=True, + ) + + # The service should return a dictionary with "result" key containing list of entity_ids + 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_MEMBERS, + {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. + + If the service is not fully implemented for cross-platform grouping, + it should return only entities from the same platform as the calling entity. + """ + await async_setup_component( + hass, "media_player", {"media_player": {"platform": "demo"}} + ) + await hass.async_block_till_done() + + # Get groupable players for the walkman music player (demo platform) + result = await hass.services.async_call( + "media_player", + SERVICE_GET_GROUPABLE_MEMBERS, + {ATTR_ENTITY_ID: "media_player.walkman"}, + blocking=True, + return_response=True, + ) + + groupable = result["media_player.walkman"]["result"] + assert isinstance(groupable, list) + + # Verify that all returned entities are from the demo platform + # and have grouping support (music players) + for entity_id in groupable: + # All entities should be media_player entities + assert entity_id.startswith("media_player.") + # They should be from the demo platform (music players with grouping support) + # The demo setup creates: walkman, kitchen (music players with grouping support) + # So we should only see these two entities for the same platform + assert entity_id in [ + "media_player.walkman", + "media_player.kitchen", + ], f"Unexpected entity {entity_id} in groupable players" + + +async def test_get_groupable_players_multiplatform(hass: HomeAssistant) -> None: + """Test that get_groupable_players can return players from different platforms. + + This tests cross-platform grouping capability when the service is implemented + to support grouping entities from different platforms. + """ + # Set up demo media player platform + await async_setup_component( + hass, "media_player", {"media_player": {"platform": "demo"}} + ) + await hass.async_block_till_done() + + # Create entity IDs for different platforms + spotify_id = "media_player.spotify" + sonos_id = "media_player.sonos_living_room" + airplay_id = "media_player.airplay_speaker" + + # Mock the sync get_groupable_players to return cross-platform results + multiplatform_result = { + "result": [ + spotify_id, + sonos_id, + airplay_id, + ] + } + + with patch( + "homeassistant.components.media_player.MediaPlayerEntity.get_groupable_players", + return_value=multiplatform_result, + ) as mock_get_groupable: + # Call the mocked method and verify it returns multiplatform results + result = mock_get_groupable() + assert isinstance(result["result"], list) + assert len(result["result"]) == 3 + assert spotify_id in result["result"] + assert sonos_id in result["result"] + assert airplay_id in result["result"] From 70ae9ae0c93b468ad480a6cabf888ef3530db43d Mon Sep 17 00:00:00 2001 From: dan-s-github Date: Tue, 20 Jan 2026 07:43:37 +0000 Subject: [PATCH 03/15] address review comments --- .../components/media_player/__init__.py | 31 ++++++++------- tests/components/media_player/test_init.py | 38 +++++++++++++------ 2 files changed, 45 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index dd583d169d505..e895a2e8b9ad5 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -1200,8 +1200,11 @@ async def async_unjoin_player(self) -> None: """Remove this player from any group.""" await self.hass.async_add_executor_job(self.unjoin_player) - def get_groupable_players(self) -> dict[str, Any]: + async def async_get_groupable_players(self) -> dict[str, Any]: """Return a list of players that can be grouped with this player.""" + # Check if this player supports grouping + if MediaPlayerEntityFeature.GROUPING not in self.supported_features: + return {"result": []} component = self.hass.data.get(DATA_COMPONENT) if component is None: @@ -1215,24 +1218,26 @@ def get_groupable_players(self) -> dict[str, Any]: current_platform = entry.platform - # Return only players from the same integration platform + # Build a map of entity_ids for the current platform for faster lookup + platform_entities = { + entity_id: entity + for entity in component.entities + if isinstance(entity, MediaPlayerEntity) + and (registry_entry := entity_registry.async_get(entity.entity_id)) + and registry_entry.platform == current_platform + for entity_id in (entity.entity_id,) + } + + # Return only players that support grouping, excluding the calling entity result = [ entity_id - for entity_id in [ - entity.entity_id - for entity in component.entities - if isinstance(entity, MediaPlayerEntity) - ] - if (registry_entry := entity_registry.async_get(entity_id)) - and registry_entry.platform == current_platform + for entity_id, entity in platform_entities.items() + if entity_id != self.entity_id + and (MediaPlayerEntityFeature.GROUPING in entity.supported_features) ] return {"result": result} - async def async_get_groupable_players(self) -> dict[str, Any]: - """Return a list of players that can be grouped with this player.""" - return await self.hass.async_add_executor_job(self.get_groupable_players) - async def _async_fetch_image_from_cache( self, url: str ) -> tuple[bytes | None, str | None]: diff --git a/tests/components/media_player/test_init.py b/tests/components/media_player/test_init.py index f3e9a95f50352..398ce992ef5e9 100644 --- a/tests/components/media_player/test_init.py +++ b/tests/components/media_player/test_init.py @@ -721,7 +721,7 @@ async def test_get_groupable_players_same_platform_only(hass: HomeAssistant) -> async def test_get_groupable_players_multiplatform(hass: HomeAssistant) -> None: - """Test that get_groupable_players can return players from different platforms. + """Test that get_groupable_players service can return players from different platforms. This tests cross-platform grouping capability when the service is implemented to support grouping entities from different platforms. @@ -737,7 +737,7 @@ async def test_get_groupable_players_multiplatform(hass: HomeAssistant) -> None: sonos_id = "media_player.sonos_living_room" airplay_id = "media_player.airplay_speaker" - # Mock the sync get_groupable_players to return cross-platform results + # Mock the async_get_groupable_players method to return cross-platform results multiplatform_result = { "result": [ spotify_id, @@ -747,13 +747,29 @@ async def test_get_groupable_players_multiplatform(hass: HomeAssistant) -> None: } with patch( - "homeassistant.components.media_player.MediaPlayerEntity.get_groupable_players", + "homeassistant.components.media_player.MediaPlayerEntity.async_get_groupable_players", return_value=multiplatform_result, - ) as mock_get_groupable: - # Call the mocked method and verify it returns multiplatform results - result = mock_get_groupable() - assert isinstance(result["result"], list) - assert len(result["result"]) == 3 - assert spotify_id in result["result"] - assert sonos_id in result["result"] - assert airplay_id in result["result"] + ): + # Call the service (not the method directly) + result = await hass.services.async_call( + "media_player", + SERVICE_GET_GROUPABLE_MEMBERS, + { + ATTR_ENTITY_ID: "media_player.walkman" + }, # Music player with grouping support + blocking=True, + return_response=True, + ) + + # Verify service returns proper response structure + assert "media_player.walkman" in result + assert isinstance(result["media_player.walkman"], dict) + assert "result" in result["media_player.walkman"] + + # Verify multiplatform results are returned + groupable_players = result["media_player.walkman"]["result"] + assert isinstance(groupable_players, list) + assert len(groupable_players) == 3 + assert spotify_id in groupable_players + assert sonos_id in groupable_players + assert airplay_id in groupable_players From e90b41d3fa13aec1083d74464b5b2ee11e7d87f3 Mon Sep 17 00:00:00 2001 From: Daniel M Date: Wed, 21 Jan 2026 19:37:15 +1300 Subject: [PATCH 04/15] Update tests/components/media_player/test_init.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- tests/components/media_player/test_init.py | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/tests/components/media_player/test_init.py b/tests/components/media_player/test_init.py index 398ce992ef5e9..f81f12e184b57 100644 --- a/tests/components/media_player/test_init.py +++ b/tests/components/media_player/test_init.py @@ -706,18 +706,20 @@ async def test_get_groupable_players_same_platform_only(hass: HomeAssistant) -> groupable = result["media_player.walkman"]["result"] assert isinstance(groupable, list) - # Verify that all returned entities are from the demo platform - # and have grouping support (music players) + # The calling entity should not appear in its own groupable list + assert "media_player.walkman" not in groupable + # The demo setup creates walkman and kitchen as music players with grouping support, + # so for same-platform grouping we should only see kitchen here. + assert "media_player.kitchen" in groupable + + # Verify that all returned entities are media_player entities and from the demo platform for entity_id in groupable: # All entities should be media_player entities assert entity_id.startswith("media_player.") - # They should be from the demo platform (music players with grouping support) - # The demo setup creates: walkman, kitchen (music players with grouping support) - # So we should only see these two entities for the same platform - assert entity_id in [ - "media_player.walkman", - "media_player.kitchen", - ], f"Unexpected entity {entity_id} in groupable players" + # Only the kitchen music player (demo platform) should be present + assert entity_id == "media_player.kitchen", ( + f"Unexpected entity {entity_id} in groupable players" + ) async def test_get_groupable_players_multiplatform(hass: HomeAssistant) -> None: From 412ee084117b0e65572e0f854e5fcab90fe073e6 Mon Sep 17 00:00:00 2001 From: Daniel M Date: Wed, 21 Jan 2026 19:37:37 +1300 Subject: [PATCH 05/15] Update homeassistant/components/media_player/strings.json Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/media_player/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/media_player/strings.json b/homeassistant/components/media_player/strings.json index c0abfcbda3bc5..f420bb7aba7d5 100644 --- a/homeassistant/components/media_player/strings.json +++ b/homeassistant/components/media_player/strings.json @@ -210,7 +210,7 @@ "name": "Clear playlist" }, "get_groupable_players": { - "description": "Gets a list of media players that can be grouped together for synchronized playback. Only works on supported multiroom audio systems.", + "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": { From bb3cf9263f3f3ab9702f17a43b8c019fd9bf016b Mon Sep 17 00:00:00 2001 From: Daniel M Date: Wed, 21 Jan 2026 19:38:04 +1300 Subject: [PATCH 06/15] Update homeassistant/components/media_player/services.yaml Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/media_player/services.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/media_player/services.yaml b/homeassistant/components/media_player/services.yaml index 10e4dba21ada4..d061e206241f1 100644 --- a/homeassistant/components/media_player/services.yaml +++ b/homeassistant/components/media_player/services.yaml @@ -120,7 +120,7 @@ media_seek: selector: number: min: 0 - max: 9223372036854776000 + max: 9223372036854775807 step: 0.01 mode: box From e97543a4adc4dfe553b8965122c1e1bd759a7b49 Mon Sep 17 00:00:00 2001 From: Daniel M Date: Wed, 21 Jan 2026 19:38:24 +1300 Subject: [PATCH 07/15] Update homeassistant/components/media_player/const.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/media_player/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/media_player/const.py b/homeassistant/components/media_player/const.py index 8d1bcd99b8cd0..716f8e8f9ce9e 100644 --- a/homeassistant/components/media_player/const.py +++ b/homeassistant/components/media_player/const.py @@ -131,7 +131,7 @@ class MediaType(StrEnum): SERVICE_SELECT_SOUND_MODE = "select_sound_mode" SERVICE_SELECT_SOURCE = "select_source" SERVICE_UNJOIN = "unjoin" -SERVICE_GET_GROUPABLE_MEMBERS = "get_groupable_players" +SERVICE_GET_GROUPABLE_PLAYERS = "get_groupable_players" class RepeatMode(StrEnum): From 36fb0365e667bcb466619694cc80dd5a00a53d61 Mon Sep 17 00:00:00 2001 From: Daniel M Date: Wed, 21 Jan 2026 19:38:49 +1300 Subject: [PATCH 08/15] Update homeassistant/components/media_player/__init__.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/media_player/__init__.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index e895a2e8b9ad5..4b4849d267fc8 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -1202,9 +1202,6 @@ async def async_unjoin_player(self) -> None: async def async_get_groupable_players(self) -> dict[str, Any]: """Return a list of players that can be grouped with this player.""" - # Check if this player supports grouping - if MediaPlayerEntityFeature.GROUPING not in self.supported_features: - return {"result": []} component = self.hass.data.get(DATA_COMPONENT) if component is None: From b92f1004b0abe5fbdc23fd802b9af4aaaa6b5f07 Mon Sep 17 00:00:00 2001 From: Daniel M Date: Wed, 21 Jan 2026 19:40:09 +1300 Subject: [PATCH 09/15] Update homeassistant/components/media_player/__init__.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../components/media_player/__init__.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index 4b4849d267fc8..307e8a83688ca 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -1216,14 +1216,14 @@ async def async_get_groupable_players(self) -> dict[str, Any]: current_platform = entry.platform # Build a map of entity_ids for the current platform for faster lookup - platform_entities = { - entity_id: entity - for entity in component.entities - if isinstance(entity, MediaPlayerEntity) - and (registry_entry := entity_registry.async_get(entity.entity_id)) - and registry_entry.platform == current_platform - for entity_id in (entity.entity_id,) - } + platform_entities: dict[str, MediaPlayerEntity] = {} + for entity in component.entities: + if not isinstance(entity, MediaPlayerEntity): + continue + registry_entry = entity_registry.async_get(entity.entity_id) + if not registry_entry or registry_entry.platform != current_platform: + continue + platform_entities[entity.entity_id] = entity # Return only players that support grouping, excluding the calling entity result = [ From 965674061fdf456abf9ced88bb72c6eb8a095872 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 21 Jan 2026 06:43:37 +0000 Subject: [PATCH 10/15] Initial plan From 67e3b4a7755df7d9e069b1771ae3d5d7a8636984 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 21 Jan 2026 06:46:52 +0000 Subject: [PATCH 11/15] Remove misleading multiplatform test that bypasses real implementation Co-authored-by: dan-s-github <20974454+dan-s-github@users.noreply.github.com> --- tests/components/media_player/test_init.py | 55 ---------------------- 1 file changed, 55 deletions(-) diff --git a/tests/components/media_player/test_init.py b/tests/components/media_player/test_init.py index f81f12e184b57..11e815083f495 100644 --- a/tests/components/media_player/test_init.py +++ b/tests/components/media_player/test_init.py @@ -720,58 +720,3 @@ async def test_get_groupable_players_same_platform_only(hass: HomeAssistant) -> assert entity_id == "media_player.kitchen", ( f"Unexpected entity {entity_id} in groupable players" ) - - -async def test_get_groupable_players_multiplatform(hass: HomeAssistant) -> None: - """Test that get_groupable_players service can return players from different platforms. - - This tests cross-platform grouping capability when the service is implemented - to support grouping entities from different platforms. - """ - # Set up demo media player platform - await async_setup_component( - hass, "media_player", {"media_player": {"platform": "demo"}} - ) - await hass.async_block_till_done() - - # Create entity IDs for different platforms - spotify_id = "media_player.spotify" - sonos_id = "media_player.sonos_living_room" - airplay_id = "media_player.airplay_speaker" - - # Mock the async_get_groupable_players method to return cross-platform results - multiplatform_result = { - "result": [ - spotify_id, - sonos_id, - airplay_id, - ] - } - - with patch( - "homeassistant.components.media_player.MediaPlayerEntity.async_get_groupable_players", - return_value=multiplatform_result, - ): - # Call the service (not the method directly) - result = await hass.services.async_call( - "media_player", - SERVICE_GET_GROUPABLE_MEMBERS, - { - ATTR_ENTITY_ID: "media_player.walkman" - }, # Music player with grouping support - blocking=True, - return_response=True, - ) - - # Verify service returns proper response structure - assert "media_player.walkman" in result - assert isinstance(result["media_player.walkman"], dict) - assert "result" in result["media_player.walkman"] - - # Verify multiplatform results are returned - groupable_players = result["media_player.walkman"]["result"] - assert isinstance(groupable_players, list) - assert len(groupable_players) == 3 - assert spotify_id in groupable_players - assert sonos_id in groupable_players - assert airplay_id in groupable_players From 4b8f5b8b68b35b87639d3e5486b88d7a2b6cae54 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 21 Jan 2026 06:55:58 +0000 Subject: [PATCH 12/15] Add test for multi-platform grouping via method override Co-authored-by: dan-s-github <20974454+dan-s-github@users.noreply.github.com> --- tests/components/media_player/test_init.py | 61 ++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/tests/components/media_player/test_init.py b/tests/components/media_player/test_init.py index 11e815083f495..08754be458217 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 @@ -720,3 +721,63 @@ async def test_get_groupable_players_same_platform_only(hass: HomeAssistant) -> assert entity_id == "media_player.kitchen", ( f"Unexpected entity {entity_id} in groupable players" ) + + +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", + ] + } + + # Create and register the custom entity + entity = MultiPlatformMediaPlayer("media_player.test_player", "Test Player") + entity.hass = hass + entity.platform = MockEntityPlatform(hass) + + # Add entity to registry and component + component = await media_player.async_get_component(hass) + component.entities.append(entity) + await entity.async_internal_added_to_hass() + + # Call the service + result = await hass.services.async_call( + "media_player", + SERVICE_GET_GROUPABLE_MEMBERS, + {ATTR_ENTITY_ID: "media_player.test_player"}, + blocking=True, + return_response=True, + ) + + # Verify the overridden implementation's results are returned + assert "media_player.test_player" in result + 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 From 181b66048caa99ce4a953649379e53208e794f78 Mon Sep 17 00:00:00 2001 From: dan-s-github Date: Wed, 21 Jan 2026 07:56:25 +0000 Subject: [PATCH 13/15] fix const renaming --- homeassistant/components/media_player/__init__.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index 307e8a83688ca..799405c6bc0c6 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -108,7 +108,7 @@ REPEAT_MODES, SERVICE_BROWSE_MEDIA, SERVICE_CLEAR_PLAYLIST, - SERVICE_GET_GROUPABLE_MEMBERS, + SERVICE_GET_GROUPABLE_PLAYERS, SERVICE_JOIN, SERVICE_PLAY_MEDIA, SERVICE_SEARCH_MEDIA, @@ -474,7 +474,7 @@ def _rewrite_enqueue(value: dict[str, Any]) -> dict[str, Any]: [MediaPlayerEntityFeature.REPEAT_SET], ) component.async_register_entity_service( - SERVICE_GET_GROUPABLE_MEMBERS, + SERVICE_GET_GROUPABLE_PLAYERS, None, "async_get_groupable_players", [MediaPlayerEntityFeature.GROUPING], @@ -1218,8 +1218,6 @@ async def async_get_groupable_players(self) -> dict[str, Any]: # Build a map of entity_ids for the current platform for faster lookup platform_entities: dict[str, MediaPlayerEntity] = {} for entity in component.entities: - if not isinstance(entity, MediaPlayerEntity): - continue registry_entry = entity_registry.async_get(entity.entity_id) if not registry_entry or registry_entry.platform != current_platform: continue From d0c1cf226e2b77560c496f2156124d270bb9bbfa Mon Sep 17 00:00:00 2001 From: dan-s-github Date: Thu, 22 Jan 2026 07:55:20 +0000 Subject: [PATCH 14/15] code review fixes --- .../components/media_player/__init__.py | 25 +++-- tests/components/media_player/test_init.py | 94 +++++++++++++------ 2 files changed, 76 insertions(+), 43 deletions(-) diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index 799405c6bc0c6..2288c6e5c4c89 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -1201,7 +1201,7 @@ async def async_unjoin_player(self) -> None: await self.hass.async_add_executor_job(self.unjoin_player) async def async_get_groupable_players(self) -> dict[str, Any]: - """Return a list of players that can be grouped with this player.""" + """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: @@ -1215,20 +1215,19 @@ async def async_get_groupable_players(self) -> dict[str, Any]: current_platform = entry.platform - # Build a map of entity_ids for the current platform for faster lookup - platform_entities: dict[str, MediaPlayerEntity] = {} - for entity in component.entities: - registry_entry = entity_registry.async_get(entity.entity_id) - if not registry_entry or registry_entry.platform != current_platform: - continue - platform_entities[entity.entity_id] = entity - + # 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_id - for entity_id, entity in platform_entities.items() - if entity_id != self.entity_id - and (MediaPlayerEntityFeature.GROUPING in entity.supported_features) + 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} diff --git a/tests/components/media_player/test_init.py b/tests/components/media_player/test_init.py index 08754be458217..33be863e7ba8e 100644 --- a/tests/components/media_player/test_init.py +++ b/tests/components/media_player/test_init.py @@ -22,7 +22,7 @@ ) from homeassistant.components.media_player.const import ( SERVICE_BROWSE_MEDIA, - SERVICE_GET_GROUPABLE_MEMBERS, + SERVICE_GET_GROUPABLE_PLAYERS, SERVICE_SEARCH_MEDIA, ) from homeassistant.components.websocket_api import TYPE_RESULT @@ -649,7 +649,7 @@ async def test_get_groupable_players_service(hass: HomeAssistant) -> None: result = await hass.services.async_call( "media_player", - SERVICE_GET_GROUPABLE_MEMBERS, + SERVICE_GET_GROUPABLE_PLAYERS, {ATTR_ENTITY_ID: "media_player.walkman"}, # Music player with grouping support blocking=True, return_response=True, @@ -677,7 +677,7 @@ async def test_get_groupable_players_entity_without_feature( ): # Should raise since bedroom doesn't support grouping await hass.services.async_call( "media_player", - SERVICE_GET_GROUPABLE_MEMBERS, + SERVICE_GET_GROUPABLE_PLAYERS, {ATTR_ENTITY_ID: "media_player.bedroom"}, blocking=True, return_response=True, @@ -687,40 +687,66 @@ async def test_get_groupable_players_entity_without_feature( async def test_get_groupable_players_same_platform_only(hass: HomeAssistant) -> None: """Test that get_groupable_players returns only entities from the same platform. - If the service is not fully implemented for cross-platform grouping, - it should return only entities from the same platform as the calling entity. + The default implementation returns only entities from the same platform + as the calling entity. """ - await async_setup_component( - hass, "media_player", {"media_player": {"platform": "demo"}} - ) + + 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 the walkman music player (demo platform) + # Get groupable players for player1 result = await hass.services.async_call( "media_player", - SERVICE_GET_GROUPABLE_MEMBERS, - {ATTR_ENTITY_ID: "media_player.walkman"}, + SERVICE_GET_GROUPABLE_PLAYERS, + {ATTR_ENTITY_ID: "media_player.player_1"}, blocking=True, return_response=True, ) - groupable = result["media_player.walkman"]["result"] + 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.walkman" not in groupable - # The demo setup creates walkman and kitchen as music players with grouping support, - # so for same-platform grouping we should only see kitchen here. - assert "media_player.kitchen" in groupable - - # Verify that all returned entities are media_player entities and from the demo platform - for entity_id in groupable: - # All entities should be media_player entities - assert entity_id.startswith("media_player.") - # Only the kitchen music player (demo platform) should be present - assert entity_id == "media_player.kitchen", ( - f"Unexpected entity {entity_id} in groupable players" - ) + 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( @@ -754,20 +780,28 @@ async def async_get_groupable_players(self) -> dict[str, Any]: ] } + # 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) - # Add entity to registry and component - component = await media_player.async_get_component(hass) - component.entities.append(entity) - await entity.async_internal_added_to_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_MEMBERS, + SERVICE_GET_GROUPABLE_PLAYERS, {ATTR_ENTITY_ID: "media_player.test_player"}, blocking=True, return_response=True, From b1db93e883ef644f3bb3c59169947e51708f3184 Mon Sep 17 00:00:00 2001 From: dan-s-github Date: Sat, 24 Jan 2026 21:29:59 +0000 Subject: [PATCH 15/15] fix test warnings --- tests/components/media_player/test_init.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/tests/components/media_player/test_init.py b/tests/components/media_player/test_init.py index 33be863e7ba8e..aab07a5895ed5 100644 --- a/tests/components/media_player/test_init.py +++ b/tests/components/media_player/test_init.py @@ -640,8 +640,11 @@ async def test_play_media_via_selector(hass: HomeAssistant) -> None: async def test_get_groupable_players_service(hass: HomeAssistant) -> None: - """Test get_groupable_players service call returns groupable players.""" - # Use DemoMusicPlayer which has GROUPING support + """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"}} ) @@ -650,12 +653,13 @@ async def test_get_groupable_players_service(hass: HomeAssistant) -> None: result = await hass.services.async_call( "media_player", SERVICE_GET_GROUPABLE_PLAYERS, - {ATTR_ENTITY_ID: "media_player.walkman"}, # Music player with grouping support + {ATTR_ENTITY_ID: "media_player.walkman"}, blocking=True, return_response=True, ) - # The service should return a dictionary with "result" key containing list of entity_ids + # 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"] @@ -737,6 +741,8 @@ def __init__(self, unique_id: str, name: str) -> None: 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) @@ -808,7 +814,9 @@ async def async_get_groupable_players(self) -> dict[str, Any]: ) # 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