From 0525c75686d11abed876e7d4dc23a702b47604b8 Mon Sep 17 00:00:00 2001 From: Louis Christ Date: Tue, 23 Dec 2025 21:48:28 +0100 Subject: [PATCH] Support media player grouping in bluesound integration (#159455) Co-authored-by: Joost Lekkerkerker --- .../components/bluesound/media_player.py | 150 ++++++++++++++++-- .../components/bluesound/strings.json | 8 + homeassistant/components/bluesound/utils.py | 11 ++ .../snapshots/test_media_player.ambr | 3 +- .../components/bluesound/test_media_player.py | 105 +++++++++++- 5 files changed, 260 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/bluesound/media_player.py b/homeassistant/components/bluesound/media_player.py index 115c6d054af..d9303a2381b 100644 --- a/homeassistant/components/bluesound/media_player.py +++ b/homeassistant/components/bluesound/media_player.py @@ -25,6 +25,7 @@ from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import ( config_validation as cv, entity_platform, + entity_registry as er, issue_registry as ir, ) from homeassistant.helpers.device_registry import ( @@ -42,7 +43,12 @@ from homeassistant.util import dt as dt_util, slugify from .const import ATTR_BLUESOUND_GROUP, ATTR_MASTER, DOMAIN from .coordinator import BluesoundCoordinator -from .utils import dispatcher_join_signal, dispatcher_unjoin_signal, format_unique_id +from .utils import ( + dispatcher_join_signal, + dispatcher_unjoin_signal, + format_unique_id, + id_to_paired_player, +) if TYPE_CHECKING: from . import BluesoundConfigEntry @@ -83,9 +89,11 @@ async def async_setup_entry( SERVICE_CLEAR_TIMER, None, "async_clear_timer" ) platform.async_register_entity_service( - SERVICE_JOIN, {vol.Required(ATTR_MASTER): cv.entity_id}, "async_join" + SERVICE_JOIN, {vol.Required(ATTR_MASTER): cv.entity_id}, "async_bluesound_join" + ) + platform.async_register_entity_service( + SERVICE_UNJOIN, None, "async_bluesound_unjoin" ) - platform.async_register_entity_service(SERVICE_UNJOIN, None, "async_unjoin") async_add_entities([bluesound_player], update_before_add=True) @@ -120,6 +128,7 @@ class BluesoundPlayer(CoordinatorEntity[BluesoundCoordinator], MediaPlayerEntity self._presets: list[Preset] = coordinator.data.presets self._group_name: str | None = None self._group_list: list[str] = [] + self._group_members: list[str] | None = None self._bluesound_device_name = sync_status.name self._player = player self._last_status_update = dt_util.utcnow() @@ -180,6 +189,7 @@ class BluesoundPlayer(CoordinatorEntity[BluesoundCoordinator], MediaPlayerEntity self._last_status_update = dt_util.utcnow() self._group_list = self.rebuild_bluesound_group() + self._group_members = self.rebuild_group_members() self.async_write_ha_state() @@ -365,11 +375,13 @@ class BluesoundPlayer(CoordinatorEntity[BluesoundCoordinator], MediaPlayerEntity MediaPlayerEntityFeature.VOLUME_STEP | MediaPlayerEntityFeature.VOLUME_SET | MediaPlayerEntityFeature.VOLUME_MUTE + | MediaPlayerEntityFeature.GROUPING ) supported = ( MediaPlayerEntityFeature.CLEAR_PLAYLIST | MediaPlayerEntityFeature.BROWSE_MEDIA + | MediaPlayerEntityFeature.GROUPING ) if not self._status.indexing: @@ -421,8 +433,57 @@ class BluesoundPlayer(CoordinatorEntity[BluesoundCoordinator], MediaPlayerEntity return shuffle - async def async_join(self, master: str) -> None: + @property + def group_members(self) -> list[str] | None: + """Get list of group members. Leader is always first.""" + return self._group_members + + async def async_join_players(self, group_members: list[str]) -> None: + """Join `group_members` as a player group with the current player.""" + if self.entity_id in group_members: + raise ServiceValidationError("Cannot join player to itself") + + entity_ids_with_sync_status = self._entity_ids_with_sync_status() + + paired_players = [] + for group_member in group_members: + sync_status = entity_ids_with_sync_status.get(group_member) + if sync_status is None: + continue + paired_player = id_to_paired_player(sync_status.id) + if paired_player: + paired_players.append(paired_player) + + if paired_players: + await self._player.add_followers(paired_players) + + async def async_unjoin_player(self) -> None: + """Remove this player from any group.""" + if self._sync_status.leader is not None: + leader_id = f"{self._sync_status.leader.ip}:{self._sync_status.leader.port}" + async_dispatcher_send( + self.hass, dispatcher_unjoin_signal(leader_id), self.host, self.port + ) + + if self._sync_status.followers is not None: + await self._player.remove_follower(self.host, self.port) + + async def async_bluesound_join(self, master: str) -> None: """Join the player to a group.""" + ir.async_create_issue( + self.hass, + DOMAIN, + f"deprecated_service_{SERVICE_JOIN}", + is_fixable=False, + breaks_in_ha_version="2026.7.0", + issue_domain=DOMAIN, + severity=ir.IssueSeverity.WARNING, + translation_key="deprecated_service_join", + translation_placeholders={ + "name": slugify(self.sync_status.name), + }, + ) + if master == self.entity_id: raise ServiceValidationError("Cannot join player to itself") @@ -431,18 +492,24 @@ class BluesoundPlayer(CoordinatorEntity[BluesoundCoordinator], MediaPlayerEntity self.hass, dispatcher_join_signal(master), self.host, self.port ) - async def async_unjoin(self) -> None: + async def async_bluesound_unjoin(self) -> None: """Unjoin the player from a group.""" - if self._sync_status.leader is None: - return - - leader_id = f"{self._sync_status.leader.ip}:{self._sync_status.leader.port}" - - _LOGGER.debug("Trying to unjoin player: %s", self.id) - async_dispatcher_send( - self.hass, dispatcher_unjoin_signal(leader_id), self.host, self.port + ir.async_create_issue( + self.hass, + DOMAIN, + f"deprecated_service_{SERVICE_UNJOIN}", + is_fixable=False, + breaks_in_ha_version="2026.7.0", + issue_domain=DOMAIN, + severity=ir.IssueSeverity.WARNING, + translation_key="deprecated_service_unjoin", + translation_placeholders={ + "name": slugify(self.sync_status.name), + }, ) + await self.async_unjoin_player() + @property def extra_state_attributes(self) -> dict[str, Any] | None: """List members in group.""" @@ -488,6 +555,63 @@ class BluesoundPlayer(CoordinatorEntity[BluesoundCoordinator], MediaPlayerEntity follower_names.insert(0, leader_sync_status.name) return follower_names + def rebuild_group_members(self) -> list[str] | None: + """Get list of group members. Leader is always first.""" + if self.sync_status.leader is None and self.sync_status.followers is None: + return None + + entity_ids_with_sync_status = self._entity_ids_with_sync_status() + + leader_entity_id = None + followers = None + if self.sync_status.followers is not None: + leader_entity_id = self.entity_id + followers = self.sync_status.followers + elif self.sync_status.leader is not None: + leader_id = f"{self.sync_status.leader.ip}:{self.sync_status.leader.port}" + for entity_id, sync_status in entity_ids_with_sync_status.items(): + if sync_status.id == leader_id: + leader_entity_id = entity_id + followers = sync_status.followers + break + + if leader_entity_id is None or followers is None: + return None + + grouped_entity_ids = [leader_entity_id] + for follower in followers: + follower_id = f"{follower.ip}:{follower.port}" + entity_ids = [ + entity_id + for entity_id, sync_status in entity_ids_with_sync_status.items() + if sync_status.id == follower_id + ] + match entity_ids: + case [entity_id]: + grouped_entity_ids.append(entity_id) + + return grouped_entity_ids + + def _entity_ids_with_sync_status(self) -> dict[str, SyncStatus]: + result = {} + + entity_registry = er.async_get(self.hass) + + config_entries: list[BluesoundConfigEntry] = ( + self.hass.config_entries.async_entries(DOMAIN) + ) + for config_entry in config_entries: + entity_entries = er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id + ) + for entity_entry in entity_entries: + if entity_entry.domain == "media_player": + result[entity_entry.entity_id] = ( + config_entry.runtime_data.coordinator.data.sync_status + ) + + return result + async def async_add_follower(self, host: str, port: int) -> None: """Add follower to leader.""" await self._player.add_follower(host, port) diff --git a/homeassistant/components/bluesound/strings.json b/homeassistant/components/bluesound/strings.json index 66689003af1..97cc6ac752d 100644 --- a/homeassistant/components/bluesound/strings.json +++ b/homeassistant/components/bluesound/strings.json @@ -41,9 +41,17 @@ "description": "Use `button.{name}_clear_sleep_timer` instead.\n\nPlease replace this action and adjust your automations and scripts.", "title": "Detected use of deprecated action bluesound.clear_sleep_timer" }, + "deprecated_service_join": { + "description": "Use the `media_player.join` action instead.\n\nPlease replace this action and adjust your automations and scripts.", + "title": "Detected use of deprecated action bluesound.join" + }, "deprecated_service_set_sleep_timer": { "description": "Use `button.{name}_set_sleep_timer` instead.\n\nPlease replace this action and adjust your automations and scripts.", "title": "Detected use of deprecated action bluesound.set_sleep_timer" + }, + "deprecated_service_unjoin": { + "description": "Use the `media_player.unjoin` action instead.\n\nPlease replace this action and adjust your automations and scripts.", + "title": "Detected use of deprecated action bluesound.unjoin" } }, "services": { diff --git a/homeassistant/components/bluesound/utils.py b/homeassistant/components/bluesound/utils.py index 5df5b32de95..f5ab6a6639f 100644 --- a/homeassistant/components/bluesound/utils.py +++ b/homeassistant/components/bluesound/utils.py @@ -1,5 +1,7 @@ """Utility functions for the Bluesound component.""" +from pyblu import PairedPlayer + from homeassistant.helpers.device_registry import format_mac @@ -19,3 +21,12 @@ def dispatcher_unjoin_signal(leader_id: str) -> str: Id is ip_address:port. This can be obtained from sync_status.id. """ return f"bluesound_unjoin_{leader_id}" + + +def id_to_paired_player(id: str) -> PairedPlayer | None: + """Try to convert id in format 'ip:port' to PairedPlayer. Returns None if unable to do so.""" + match id.rsplit(":", 1): + case [str() as ip, str() as port] if port.isdigit(): + return PairedPlayer(ip, int(port)) + case _: + return None diff --git a/tests/components/bluesound/snapshots/test_media_player.ambr b/tests/components/bluesound/snapshots/test_media_player.ambr index 24e04160e90..73ae06945a8 100644 --- a/tests/components/bluesound/snapshots/test_media_player.ambr +++ b/tests/components/bluesound/snapshots/test_media_player.ambr @@ -3,6 +3,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'player-name1111', + 'group_members': None, 'is_volume_muted': False, 'master': False, 'media_album_name': 'album', @@ -19,7 +20,7 @@ 'input3', '4', ]), - 'supported_features': , + 'supported_features': , 'volume_level': 0.1, }), 'context': , diff --git a/tests/components/bluesound/test_media_player.py b/tests/components/bluesound/test_media_player.py index b534c7aafb0..a68957e01a6 100644 --- a/tests/components/bluesound/test_media_player.py +++ b/tests/components/bluesound/test_media_player.py @@ -13,18 +13,20 @@ from homeassistant.components.bluesound import DOMAIN from homeassistant.components.bluesound.const import ATTR_MASTER from homeassistant.components.bluesound.media_player import ( SERVICE_CLEAR_TIMER, - SERVICE_JOIN, SERVICE_SET_TIMER, ) from homeassistant.components.media_player import ( + ATTR_GROUP_MEMBERS, ATTR_INPUT_SOURCE, ATTR_MEDIA_VOLUME_LEVEL, DOMAIN as MEDIA_PLAYER_DOMAIN, + SERVICE_JOIN, SERVICE_MEDIA_NEXT_TRACK, SERVICE_MEDIA_PAUSE, SERVICE_MEDIA_PLAY, SERVICE_MEDIA_PREVIOUS_TRACK, SERVICE_SELECT_SOURCE, + SERVICE_UNJOIN, SERVICE_VOLUME_DOWN, SERVICE_VOLUME_MUTE, SERVICE_VOLUME_SET, @@ -291,7 +293,7 @@ async def test_join( setup_config_entry_secondary: None, player_mocks: PlayerMocks, ) -> None: - """Test the join action.""" + """Test the bluesound.join action.""" await hass.services.async_call( DOMAIN, SERVICE_JOIN, @@ -313,7 +315,7 @@ async def test_unjoin( setup_config_entry_secondary: None, player_mocks: PlayerMocks, ) -> None: - """Test the unjoin action.""" + """Test the bluesound.unjoin action.""" updated_sync_status = dataclasses.replace( player_mocks.player_data.sync_status_long_polling_mock.get(), leader=PairedPlayer("2.2.2.2", 11000), @@ -455,3 +457,100 @@ async def test_volume_up_from_6_to_7( ) player_mocks.player_data.player.volume.assert_called_once_with(level=7) + + +async def test_attr_group_members( + hass: HomeAssistant, + setup_config_entry: None, + setup_config_entry_secondary: None, + player_mocks: PlayerMocks, +) -> None: + """Test the media player grouping for leader.""" + attr_group_members = hass.states.get("media_player.player_name1111").attributes.get( + ATTR_GROUP_MEMBERS + ) + assert attr_group_members is None + + updated_sync_status = dataclasses.replace( + player_mocks.player_data.sync_status_long_polling_mock.get(), + followers=[PairedPlayer("2.2.2.2", 11000)], + ) + player_mocks.player_data.sync_status_long_polling_mock.set(updated_sync_status) + + # give the long polling loop a chance to update the state; this could be any async call + await hass.async_block_till_done() + + attr_group_members = hass.states.get("media_player.player_name1111").attributes.get( + ATTR_GROUP_MEMBERS + ) + + assert attr_group_members == [ + "media_player.player_name1111", + "media_player.player_name2222", + ] + + +async def test_join_players( + hass: HomeAssistant, + setup_config_entry: None, + setup_config_entry_secondary: None, + player_mocks: PlayerMocks, +) -> None: + """Test the media_player.join action.""" + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_JOIN, + { + ATTR_ENTITY_ID: "media_player.player_name1111", + ATTR_GROUP_MEMBERS: "media_player.player_name2222", + }, + blocking=True, + ) + + player_mocks.player_data.player.add_followers.assert_called_once_with( + [PairedPlayer("2.2.2.2", 11000)] + ) + + +async def test_join_player_cannot_join_to_self( + hass: HomeAssistant, setup_config_entry: None, player_mocks: PlayerMocks +) -> None: + """Test that joining to self is not allowed.""" + with pytest.raises(ServiceValidationError, match="Cannot join player to itself"): + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_JOIN, + { + ATTR_ENTITY_ID: "media_player.player_name1111", + ATTR_GROUP_MEMBERS: "media_player.player_name1111", + }, + blocking=True, + ) + + +async def test_unjoin_player( + hass: HomeAssistant, + setup_config_entry: None, + setup_config_entry_secondary: None, + player_mocks: PlayerMocks, +) -> None: + """Test the media_player.unjoin action.""" + updated_sync_status = dataclasses.replace( + player_mocks.player_data.sync_status_long_polling_mock.get(), + leader=PairedPlayer("2.2.2.2", 11000), + ) + player_mocks.player_data.sync_status_long_polling_mock.set(updated_sync_status) + + # give the long polling loop a chance to update the state; this could be any async call + await hass.async_block_till_done() + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_UNJOIN, + {ATTR_ENTITY_ID: "media_player.player_name1111"}, + blocking=True, + ) + + player_mocks.player_data_secondary.player.remove_follower.assert_called_once_with( + "1.1.1.1", 11000 + )