diff --git a/homeassistant/components/bluesound/__init__.py b/homeassistant/components/bluesound/__init__.py index 82fe9b00d57..b3facc0b8ac 100644 --- a/homeassistant/components/bluesound/__init__.py +++ b/homeassistant/components/bluesound/__init__.py @@ -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 diff --git a/homeassistant/components/bluesound/manifest.json b/homeassistant/components/bluesound/manifest.json index 462112a8b78..151c1512b74 100644 --- a/homeassistant/components/bluesound/manifest.json +++ b/homeassistant/components/bluesound/manifest.json @@ -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." diff --git a/homeassistant/components/bluesound/media_player.py b/homeassistant/components/bluesound/media_player.py index 38ef78fad3a..4882d543617 100644 --- a/homeassistant/components/bluesound/media_player.py +++ b/homeassistant/components/bluesound/media_player.py @@ -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): diff --git a/homeassistant/components/bluesound/services.py b/homeassistant/components/bluesound/services.py deleted file mode 100644 index 06a507420f8..00000000000 --- a/homeassistant/components/bluesound/services.py +++ /dev/null @@ -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 - ) diff --git a/homeassistant/components/bluesound/utils.py b/homeassistant/components/bluesound/utils.py index 89a6fd1e787..5df5b32de95 100644 --- a/homeassistant/components/bluesound/utils.py +++ b/homeassistant/components/bluesound/utils.py @@ -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}" diff --git a/requirements_all.txt b/requirements_all.txt index 2540a297334..2bcbf0535c6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -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 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fe528899ad3..9cdb1039503 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -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 diff --git a/tests/components/bluesound/conftest.py b/tests/components/bluesound/conftest.py index b4ee61dee57..717c9f61850 100644 --- a/tests/components/bluesound/conftest.py +++ b/tests/components/bluesound/conftest.py @@ -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, ) diff --git a/tests/components/bluesound/test_media_player.py b/tests/components/bluesound/test_media_player.py index 217225628f2..a43696a0a7f 100644 --- a/tests/components/bluesound/test_media_player.py +++ b/tests/components/bluesound/test_media_player.py @@ -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