Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
150 changes: 137 additions & 13 deletions homeassistant/components/bluesound/media_player.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
from homeassistant.helpers import (
config_validation as cv,
entity_platform,
entity_registry as er,
issue_registry as ir,
)
from homeassistant.helpers.device_registry import (
Expand All @@ -42,7 +43,12 @@

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
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -120,6 +128,7 @@ def __init__(
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()
Expand Down Expand Up @@ -180,6 +189,7 @@ def _handle_coordinator_update(self) -> None:
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()

Expand Down Expand Up @@ -365,11 +375,13 @@ def supported_features(self) -> MediaPlayerEntityFeature:
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:
Expand Down Expand Up @@ -421,8 +433,57 @@ def shuffle(self) -> bool:

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)

Comment thread
LouisChrist marked this conversation as resolved.
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:
Comment thread
LouisChrist marked this conversation as resolved.
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")

Expand All @@ -431,18 +492,24 @@ async def async_join(self, master: str) -> None:
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."""
Expand Down Expand Up @@ -488,6 +555,63 @@ def rebuild_bluesound_group(self) -> list[str]:
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
Comment thread
LouisChrist marked this conversation as resolved.

async def async_add_follower(self, host: str, port: int) -> None:
"""Add follower to leader."""
await self._player.add_follower(host, port)
Expand Down
8 changes: 8 additions & 0 deletions homeassistant/components/bluesound/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Comment thread
LouisChrist marked this conversation as resolved.
}
},
"services": {
Expand Down
11 changes: 11 additions & 0 deletions homeassistant/components/bluesound/utils.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
"""Utility functions for the Bluesound component."""

from pyblu import PairedPlayer

from homeassistant.helpers.device_registry import format_mac


Expand All @@ -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):
Comment thread
LouisChrist marked this conversation as resolved.
case [str() as ip, str() as port] if port.isdigit():
return PairedPlayer(ip, int(port))
case _:
return None
3 changes: 2 additions & 1 deletion tests/components/bluesound/snapshots/test_media_player.ambr
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'player-name1111',
'group_members': None,
'is_volume_muted': False,
'master': False,
'media_album_name': 'album',
Expand All @@ -19,7 +20,7 @@
'input3',
'4',
]),
'supported_features': <MediaPlayerEntityFeature: 196157>,
'supported_features': <MediaPlayerEntityFeature: 720445>,
'volume_level': 0.1,
}),
'context': <ANY>,
Expand Down
Loading
Loading