mirror of
https://github.com/Electric-Special/ha-core.git
synced 2026-03-21 05:06:13 +01:00
Support media player grouping in bluesound integration (#159455)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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': <MediaPlayerEntityFeature: 196157>,
|
||||
'supported_features': <MediaPlayerEntityFeature: 720445>,
|
||||
'volume_level': 0.1,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user