Use entity services in bluesound integration (#129266)

This commit is contained in:
Louis Christ 2024-12-17 20:44:38 +01:00 committed by GitHub
parent c9ca1f63ea
commit 9c26654db7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 142 additions and 164 deletions

View File

@ -14,7 +14,6 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.typing import ConfigType
from .const import DOMAIN
from .services import setup_services
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
@ -36,7 +35,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Bluesound."""
if DOMAIN not in hass.data:
hass.data[DOMAIN] = []
setup_services(hass)
return True

View File

@ -6,7 +6,7 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/bluesound",
"iot_class": "local_polling",
"requirements": ["pyblu==1.0.4"],
"requirements": ["pyblu==2.0.0"],
"zeroconf": [
{
"type": "_musc._tcp.local."

View File

@ -28,18 +28,26 @@ from homeassistant.const import CONF_HOST, CONF_HOSTS, CONF_NAME, CONF_PORT
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import config_validation as cv, issue_registry as ir
from homeassistant.helpers import (
config_validation as cv,
entity_platform,
issue_registry as ir,
)
from homeassistant.helpers.device_registry import (
CONNECTION_NETWORK_MAC,
DeviceInfo,
format_mac,
)
from homeassistant.helpers.dispatcher import (
async_dispatcher_connect,
async_dispatcher_send,
)
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
import homeassistant.util.dt as dt_util
from .const import ATTR_BLUESOUND_GROUP, ATTR_MASTER, DOMAIN, INTEGRATION_TITLE
from .utils import format_unique_id
from .utils import dispatcher_join_signal, dispatcher_unjoin_signal, format_unique_id
if TYPE_CHECKING:
from . import BluesoundConfigEntry
@ -51,6 +59,11 @@ SCAN_INTERVAL = timedelta(minutes=15)
DATA_BLUESOUND = DOMAIN
DEFAULT_PORT = 11000
SERVICE_CLEAR_TIMER = "clear_sleep_timer"
SERVICE_JOIN = "join"
SERVICE_SET_TIMER = "set_sleep_timer"
SERVICE_UNJOIN = "unjoin"
NODE_OFFLINE_CHECK_TIMEOUT = 180
NODE_RETRY_INITIATION = timedelta(minutes=3)
@ -130,6 +143,18 @@ async def async_setup_entry(
config_entry.runtime_data.sync_status,
)
platform = entity_platform.async_get_current_platform()
platform.async_register_entity_service(
SERVICE_SET_TIMER, None, "async_increase_timer"
)
platform.async_register_entity_service(
SERVICE_CLEAR_TIMER, None, "async_clear_timer"
)
platform.async_register_entity_service(
SERVICE_JOIN, {vol.Required(ATTR_MASTER): cv.entity_id}, "async_join"
)
platform.async_register_entity_service(SERVICE_UNJOIN, None, "async_unjoin")
hass.data[DATA_BLUESOUND].append(bluesound_player)
async_add_entities([bluesound_player], update_before_add=True)
@ -175,13 +200,12 @@ class BluesoundPlayer(MediaPlayerEntity):
self._status: Status | None = None
self._inputs: list[Input] = []
self._presets: list[Preset] = []
self._muted = False
self._master: BluesoundPlayer | None = None
self._is_master = False
self._group_name: str | None = None
self._group_list: list[str] = []
self._bluesound_device_name = sync_status.name
self._player = player
self._is_leader = False
self._leader: BluesoundPlayer | None = None
self._attr_unique_id = format_unique_id(sync_status.mac, port)
# there should always be one player with the default port per mac
@ -250,6 +274,22 @@ class BluesoundPlayer(MediaPlayerEntity):
name=f"bluesound.poll_sync_status_loop_{self.host}:{self.port}",
)
assert self._sync_status.id is not None
self.async_on_remove(
async_dispatcher_connect(
self.hass,
dispatcher_join_signal(self.entity_id),
self.async_add_follower,
)
)
self.async_on_remove(
async_dispatcher_connect(
self.hass,
dispatcher_unjoin_signal(self._sync_status.id),
self.async_remove_follower,
)
)
async def async_will_remove_from_hass(self) -> None:
"""Stop the polling task."""
await super().async_will_remove_from_hass()
@ -317,25 +357,25 @@ class BluesoundPlayer(MediaPlayerEntity):
self._group_list = self.rebuild_bluesound_group()
if sync_status.master is not None:
self._is_master = False
master_id = f"{sync_status.master.ip}:{sync_status.master.port}"
master_device = [
if sync_status.leader is not None:
self._is_leader = False
leader_id = f"{sync_status.leader.ip}:{sync_status.leader.port}"
leader_device = [
device
for device in self.hass.data[DATA_BLUESOUND]
if device.id == master_id
if device.id == leader_id
]
if master_device and master_id != self.id:
self._master = master_device[0]
if leader_device and leader_id != self.id:
self._leader = leader_device[0]
else:
self._master = None
_LOGGER.error("Master not found %s", master_id)
self._leader = None
_LOGGER.error("Leader not found %s", leader_id)
else:
if self._master is not None:
self._master = None
slaves = self._sync_status.slaves
self._is_master = slaves is not None
if self._leader is not None:
self._leader = None
followers = self._sync_status.followers
self._is_leader = followers is not None
self.async_write_ha_state()
@ -355,7 +395,7 @@ class BluesoundPlayer(MediaPlayerEntity):
if self._status is None:
return MediaPlayerState.OFF
if self.is_grouped and not self.is_master:
if self.is_grouped and not self.is_leader:
return MediaPlayerState.IDLE
match self._status.state:
@ -369,7 +409,7 @@ class BluesoundPlayer(MediaPlayerEntity):
@property
def media_title(self) -> str | None:
"""Title of current playing media."""
if self._status is None or (self.is_grouped and not self.is_master):
if self._status is None or (self.is_grouped and not self.is_leader):
return None
return self._status.name
@ -380,7 +420,7 @@ class BluesoundPlayer(MediaPlayerEntity):
if self._status is None:
return None
if self.is_grouped and not self.is_master:
if self.is_grouped and not self.is_leader:
return self._group_name
return self._status.artist
@ -388,7 +428,7 @@ class BluesoundPlayer(MediaPlayerEntity):
@property
def media_album_name(self) -> str | None:
"""Artist of current playing media (Music track only)."""
if self._status is None or (self.is_grouped and not self.is_master):
if self._status is None or (self.is_grouped and not self.is_leader):
return None
return self._status.album
@ -396,7 +436,7 @@ class BluesoundPlayer(MediaPlayerEntity):
@property
def media_image_url(self) -> str | None:
"""Image url of current playing media."""
if self._status is None or (self.is_grouped and not self.is_master):
if self._status is None or (self.is_grouped and not self.is_leader):
return None
url = self._status.image
@ -411,7 +451,7 @@ class BluesoundPlayer(MediaPlayerEntity):
@property
def media_position(self) -> int | None:
"""Position of current playing media in seconds."""
if self._status is None or (self.is_grouped and not self.is_master):
if self._status is None or (self.is_grouped and not self.is_leader):
return None
mediastate = self.state
@ -430,7 +470,7 @@ class BluesoundPlayer(MediaPlayerEntity):
@property
def media_duration(self) -> int | None:
"""Duration of current playing media in seconds."""
if self._status is None or (self.is_grouped and not self.is_master):
if self._status is None or (self.is_grouped and not self.is_leader):
return None
duration = self._status.total_seconds
@ -489,7 +529,7 @@ class BluesoundPlayer(MediaPlayerEntity):
@property
def source_list(self) -> list[str] | None:
"""List of available input sources."""
if self._status is None or (self.is_grouped and not self.is_master):
if self._status is None or (self.is_grouped and not self.is_leader):
return None
sources = [x.text for x in self._inputs]
@ -500,7 +540,7 @@ class BluesoundPlayer(MediaPlayerEntity):
@property
def source(self) -> str | None:
"""Name of the current input source."""
if self._status is None or (self.is_grouped and not self.is_master):
if self._status is None or (self.is_grouped and not self.is_leader):
return None
if self._status.input_id is not None:
@ -520,7 +560,7 @@ class BluesoundPlayer(MediaPlayerEntity):
if self._status is None:
return MediaPlayerEntityFeature(0)
if self.is_grouped and not self.is_master:
if self.is_grouped and not self.is_leader:
return (
MediaPlayerEntityFeature.VOLUME_STEP
| MediaPlayerEntityFeature.VOLUME_SET
@ -560,14 +600,17 @@ class BluesoundPlayer(MediaPlayerEntity):
return supported
@property
def is_master(self) -> bool:
"""Return true if player is a coordinator."""
return self._is_master
def is_leader(self) -> bool:
"""Return true if player is leader of a group."""
return self._sync_status.followers is not None
@property
def is_grouped(self) -> bool:
"""Return true if player is a coordinator."""
return self._master is not None or self._is_master
"""Return true if player is member or leader of a group."""
return (
self._sync_status.followers is not None
or self._sync_status.leader is not None
)
@property
def shuffle(self) -> bool:
@ -580,25 +623,25 @@ class BluesoundPlayer(MediaPlayerEntity):
async def async_join(self, master: str) -> None:
"""Join the player to a group."""
master_device = [
device
for device in self.hass.data[DATA_BLUESOUND]
if device.entity_id == master
]
if master == self.entity_id:
raise ServiceValidationError("Cannot join player to itself")
if len(master_device) > 0:
if self.id == master_device[0].id:
raise ServiceValidationError("Cannot join player to itself")
_LOGGER.debug("Trying to join player: %s", self.id)
async_dispatcher_send(
self.hass, dispatcher_join_signal(master), self.host, self.port
)
_LOGGER.debug(
"Trying to join player: %s to master: %s",
self.id,
master_device[0].id,
)
async def async_unjoin(self) -> None:
"""Unjoin the player from a group."""
if self._sync_status.leader is None:
return
await master_device[0].async_add_slave(self)
else:
_LOGGER.error("Master not found %s", master_device)
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
)
@property
def extra_state_attributes(self) -> dict[str, Any] | None:
@ -607,31 +650,31 @@ class BluesoundPlayer(MediaPlayerEntity):
if self._group_list:
attributes = {ATTR_BLUESOUND_GROUP: self._group_list}
attributes[ATTR_MASTER] = self._is_master
attributes[ATTR_MASTER] = self.is_leader
return attributes
def rebuild_bluesound_group(self) -> list[str]:
"""Rebuild the list of entities in speaker group."""
if self.sync_status.master is None and self.sync_status.slaves is None:
if self.sync_status.leader is None and self.sync_status.followers is None:
return []
player_entities: list[BluesoundPlayer] = self.hass.data[DATA_BLUESOUND]
leader_sync_status: SyncStatus | None = None
if self.sync_status.master is None:
if self.sync_status.leader is None:
leader_sync_status = self.sync_status
else:
required_id = f"{self.sync_status.master.ip}:{self.sync_status.master.port}"
required_id = f"{self.sync_status.leader.ip}:{self.sync_status.leader.port}"
for x in player_entities:
if x.sync_status.id == required_id:
leader_sync_status = x.sync_status
break
if leader_sync_status is None or leader_sync_status.slaves is None:
if leader_sync_status is None or leader_sync_status.followers is None:
return []
follower_ids = [f"{x.ip}:{x.port}" for x in leader_sync_status.slaves]
follower_ids = [f"{x.ip}:{x.port}" for x in leader_sync_status.followers]
follower_names = [
x.sync_status.name
for x in player_entities
@ -640,21 +683,13 @@ class BluesoundPlayer(MediaPlayerEntity):
follower_names.insert(0, leader_sync_status.name)
return follower_names
async def async_unjoin(self) -> None:
"""Unjoin the player from a group."""
if self._master is None:
return
async def async_add_follower(self, host: str, port: int) -> None:
"""Add follower to leader."""
await self._player.add_follower(host, port)
_LOGGER.debug("Trying to unjoin player: %s", self.id)
await self._master.async_remove_slave(self)
async def async_add_slave(self, slave_device: BluesoundPlayer) -> None:
"""Add slave to master."""
await self._player.add_slave(slave_device.host, slave_device.port)
async def async_remove_slave(self, slave_device: BluesoundPlayer) -> None:
"""Remove slave to master."""
await self._player.remove_slave(slave_device.host, slave_device.port)
async def async_remove_follower(self, host: str, port: int) -> None:
"""Remove follower to leader."""
await self._player.remove_follower(host, port)
async def async_increase_timer(self) -> int:
"""Increase sleep time on player."""
@ -672,7 +707,7 @@ class BluesoundPlayer(MediaPlayerEntity):
async def async_select_source(self, source: str) -> None:
"""Select input source."""
if self.is_grouped and not self.is_master:
if self.is_grouped and not self.is_leader:
return
# presets and inputs might have the same name; presets have priority
@ -691,49 +726,49 @@ class BluesoundPlayer(MediaPlayerEntity):
async def async_clear_playlist(self) -> None:
"""Clear players playlist."""
if self.is_grouped and not self.is_master:
if self.is_grouped and not self.is_leader:
return
await self._player.clear()
async def async_media_next_track(self) -> None:
"""Send media_next command to media player."""
if self.is_grouped and not self.is_master:
if self.is_grouped and not self.is_leader:
return
await self._player.skip()
async def async_media_previous_track(self) -> None:
"""Send media_previous command to media player."""
if self.is_grouped and not self.is_master:
if self.is_grouped and not self.is_leader:
return
await self._player.back()
async def async_media_play(self) -> None:
"""Send media_play command to media player."""
if self.is_grouped and not self.is_master:
if self.is_grouped and not self.is_leader:
return
await self._player.play()
async def async_media_pause(self) -> None:
"""Send media_pause command to media player."""
if self.is_grouped and not self.is_master:
if self.is_grouped and not self.is_leader:
return
await self._player.pause()
async def async_media_stop(self) -> None:
"""Send stop command."""
if self.is_grouped and not self.is_master:
if self.is_grouped and not self.is_leader:
return
await self._player.stop()
async def async_media_seek(self, position: float) -> None:
"""Send media_seek command to media player."""
if self.is_grouped and not self.is_master:
if self.is_grouped and not self.is_leader:
return
await self._player.play(seek=int(position))
@ -742,7 +777,7 @@ class BluesoundPlayer(MediaPlayerEntity):
self, media_type: MediaType | str, media_id: str, **kwargs: Any
) -> None:
"""Send the play_media command to the media player."""
if self.is_grouped and not self.is_master:
if self.is_grouped and not self.is_leader:
return
if media_source.is_media_source_id(media_id):

View File

@ -1,68 +0,0 @@
"""Support for Bluesound devices."""
from __future__ import annotations
from typing import NamedTuple
import voluptuous as vol
from homeassistant.const import ATTR_ENTITY_ID
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.helpers import config_validation as cv
from .const import ATTR_MASTER, DOMAIN
SERVICE_CLEAR_TIMER = "clear_sleep_timer"
SERVICE_JOIN = "join"
SERVICE_SET_TIMER = "set_sleep_timer"
SERVICE_UNJOIN = "unjoin"
BS_SCHEMA = vol.Schema({vol.Optional(ATTR_ENTITY_ID): cv.entity_ids})
BS_JOIN_SCHEMA = BS_SCHEMA.extend({vol.Required(ATTR_MASTER): cv.entity_id})
class ServiceMethodDetails(NamedTuple):
"""Details for SERVICE_TO_METHOD mapping."""
method: str
schema: vol.Schema
SERVICE_TO_METHOD = {
SERVICE_JOIN: ServiceMethodDetails(method="async_join", schema=BS_JOIN_SCHEMA),
SERVICE_UNJOIN: ServiceMethodDetails(method="async_unjoin", schema=BS_SCHEMA),
SERVICE_SET_TIMER: ServiceMethodDetails(
method="async_increase_timer", schema=BS_SCHEMA
),
SERVICE_CLEAR_TIMER: ServiceMethodDetails(
method="async_clear_timer", schema=BS_SCHEMA
),
}
def setup_services(hass: HomeAssistant) -> None:
"""Set up services for Bluesound component."""
async def async_service_handler(service: ServiceCall) -> None:
"""Map services to method of Bluesound devices."""
if not (method := SERVICE_TO_METHOD.get(service.service)):
return
params = {
key: value for key, value in service.data.items() if key != ATTR_ENTITY_ID
}
if entity_ids := service.data.get(ATTR_ENTITY_ID):
target_players = [
player for player in hass.data[DOMAIN] if player.entity_id in entity_ids
]
else:
target_players = hass.data[DOMAIN]
for player in target_players:
await getattr(player, method.method)(**params)
for service, method in SERVICE_TO_METHOD.items():
hass.services.async_register(
DOMAIN, service, async_service_handler, schema=method.schema
)

View File

@ -6,3 +6,16 @@ from homeassistant.helpers.device_registry import format_mac
def format_unique_id(mac: str, port: int) -> str:
"""Generate a unique ID based on the MAC address and port number."""
return f"{format_mac(mac)}-{port}"
def dispatcher_join_signal(entity_id: str) -> str:
"""Join an entity ID with a signal."""
return f"bluesound_join_{entity_id}"
def dispatcher_unjoin_signal(leader_id: str) -> str:
"""Unjoin an entity ID with a signal.
Id is ip_address:port. This can be obtained from sync_status.id.
"""
return f"bluesound_unjoin_{leader_id}"

View File

@ -1803,7 +1803,7 @@ pybbox==0.0.5-alpha
pyblackbird==0.6
# homeassistant.components.bluesound
pyblu==1.0.4
pyblu==2.0.0
# homeassistant.components.neato
pybotvac==0.0.25

View File

@ -1477,7 +1477,7 @@ pybalboa==1.0.2
pyblackbird==0.6
# homeassistant.components.bluesound
pyblu==1.0.4
pyblu==2.0.0
# homeassistant.components.neato
pybotvac==0.0.25

View File

@ -81,11 +81,11 @@ class PlayerMockData:
volume_db=0.5,
volume=50,
group=None,
master=None,
slaves=None,
leader=None,
followers=None,
zone=None,
zone_master=None,
zone_slave=None,
zone_leader=None,
zone_follower=None,
mute_volume_db=None,
mute_volume=None,
)

View File

@ -11,7 +11,7 @@ from syrupy.filters import props
from homeassistant.components.bluesound import DOMAIN as BLUESOUND_DOMAIN
from homeassistant.components.bluesound.const import ATTR_MASTER
from homeassistant.components.bluesound.services import (
from homeassistant.components.bluesound.media_player import (
SERVICE_CLEAR_TIMER,
SERVICE_JOIN,
SERVICE_SET_TIMER,
@ -259,7 +259,7 @@ async def test_join(
blocking=True,
)
player_mocks.player_data_secondary.player.add_slave.assert_called_once_with(
player_mocks.player_data_secondary.player.add_follower.assert_called_once_with(
"1.1.1.1", 11000
)
@ -273,7 +273,7 @@ async def test_unjoin(
"""Test the unjoin action."""
updated_sync_status = dataclasses.replace(
player_mocks.player_data.sync_status_long_polling_mock.get(),
master=PairedPlayer("2.2.2.2", 11000),
leader=PairedPlayer("2.2.2.2", 11000),
)
player_mocks.player_data.sync_status_long_polling_mock.set(updated_sync_status)
@ -287,7 +287,7 @@ async def test_unjoin(
blocking=True,
)
player_mocks.player_data_secondary.player.remove_slave.assert_called_once_with(
player_mocks.player_data_secondary.player.remove_follower.assert_called_once_with(
"1.1.1.1", 11000
)
@ -297,7 +297,7 @@ async def test_attr_master(
setup_config_entry: None,
player_mocks: PlayerMocks,
) -> None:
"""Test the media player master."""
"""Test the media player leader."""
attr_master = hass.states.get("media_player.player_name1111").attributes[
ATTR_MASTER
]
@ -305,7 +305,7 @@ async def test_attr_master(
updated_sync_status = dataclasses.replace(
player_mocks.player_data.sync_status_long_polling_mock.get(),
slaves=[PairedPlayer("2.2.2.2", 11000)],
followers=[PairedPlayer("2.2.2.2", 11000)],
)
player_mocks.player_data.sync_status_long_polling_mock.set(updated_sync_status)
@ -333,7 +333,7 @@ async def test_attr_bluesound_group(
updated_sync_status = dataclasses.replace(
player_mocks.player_data.sync_status_long_polling_mock.get(),
slaves=[PairedPlayer("2.2.2.2", 11000)],
followers=[PairedPlayer("2.2.2.2", 11000)],
)
player_mocks.player_data.sync_status_long_polling_mock.set(updated_sync_status)
@ -361,7 +361,7 @@ async def test_attr_bluesound_group_for_follower(
updated_sync_status = dataclasses.replace(
player_mocks.player_data.sync_status_long_polling_mock.get(),
slaves=[PairedPlayer("2.2.2.2", 11000)],
followers=[PairedPlayer("2.2.2.2", 11000)],
)
player_mocks.player_data.sync_status_long_polling_mock.set(updated_sync_status)
@ -370,7 +370,7 @@ async def test_attr_bluesound_group_for_follower(
updated_sync_status = dataclasses.replace(
player_mocks.player_data_secondary.sync_status_long_polling_mock.get(),
master=PairedPlayer("1.1.1.1", 11000),
leader=PairedPlayer("1.1.1.1", 11000),
)
player_mocks.player_data_secondary.sync_status_long_polling_mock.set(
updated_sync_status