1 """Support for Bluesound devices."""
3 from __future__
import annotations
6 from asyncio
import CancelledError, Task
7 from contextlib
import suppress
8 from datetime
import datetime, timedelta
10 from typing
import TYPE_CHECKING, Any
12 from pyblu
import Input, Player, Preset, Status, SyncStatus
13 from pyblu.errors
import PlayerUnreachableError
14 import voluptuous
as vol
18 PLATFORM_SCHEMA
as MEDIA_PLAYER_PLATFORM_SCHEMA,
21 MediaPlayerEntityFeature,
24 async_process_play_media_url,
33 CONNECTION_NETWORK_MAC,
41 from .const
import ATTR_BLUESOUND_GROUP, ATTR_MASTER, DOMAIN, INTEGRATION_TITLE
42 from .utils
import format_unique_id
45 from .
import BluesoundConfigEntry
47 _LOGGER = logging.getLogger(__name__)
51 DATA_BLUESOUND = DOMAIN
54 NODE_OFFLINE_CHECK_TIMEOUT = 180
61 PLATFORM_SCHEMA = MEDIA_PLAYER_PLATFORM_SCHEMA.extend(
63 vol.Optional(CONF_HOSTS): vol.All(
67 vol.Required(CONF_HOST): cv.string,
68 vol.Optional(CONF_NAME): cv.string,
69 vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
77 async
def _async_import(hass: HomeAssistant, config: ConfigType) ->
None:
78 """Import config entry from configuration.yaml."""
79 if not hass.config_entries.async_entries(DOMAIN):
81 result = await hass.config_entries.flow.async_init(
82 DOMAIN, context={
"source": SOURCE_IMPORT}, data=config
85 result[
"type"] == FlowResultType.ABORT
86 and result[
"reason"] ==
"cannot_connect"
88 ir.async_create_issue(
91 f
"deprecated_yaml_import_issue_{result['reason']}",
92 breaks_in_ha_version=
"2025.2.0",
95 severity=ir.IssueSeverity.WARNING,
96 translation_key=f
"deprecated_yaml_import_issue_{result['reason']}",
97 translation_placeholders={
99 "integration_title": INTEGRATION_TITLE,
104 ir.async_create_issue(
106 HOMEASSISTANT_DOMAIN,
107 f
"deprecated_yaml_{DOMAIN}",
108 breaks_in_ha_version=
"2025.2.0",
111 severity=ir.IssueSeverity.WARNING,
112 translation_key=
"deprecated_yaml",
113 translation_placeholders={
115 "integration_title": INTEGRATION_TITLE,
122 config_entry: BluesoundConfigEntry,
123 async_add_entities: AddEntitiesCallback,
125 """Set up the Bluesound entry."""
127 config_entry.data[CONF_HOST],
128 config_entry.data[CONF_PORT],
129 config_entry.runtime_data.player,
130 config_entry.runtime_data.sync_status,
133 hass.data[DATA_BLUESOUND].append(bluesound_player)
140 async_add_entities: AddEntitiesCallback,
141 discovery_info: DiscoveryInfoType |
None,
143 """Trigger import flows."""
144 hosts = config.get(CONF_HOSTS, [])
147 CONF_HOST: host[CONF_HOST],
148 CONF_PORT: host.get(CONF_PORT, 11000),
154 """Representation of a Bluesound Player."""
156 _attr_media_content_type = MediaType.MUSIC
157 _attr_has_entity_name =
True
165 sync_status: SyncStatus,
167 """Initialize the media player."""
172 self.
_id_id = sync_status.id
175 self.
_status_status: Status |
None =
None
176 self.
_inputs_inputs: list[Input] = []
177 self.
_presets_presets: list[Preset] = []
179 self.
_master_master: BluesoundPlayer |
None =
None
181 self._group_name: str |
None =
None
188 if port == DEFAULT_PORT:
190 identifiers={(DOMAIN,
format_mac(sync_status.mac))},
191 connections={(CONNECTION_NETWORK_MAC,
format_mac(sync_status.mac))},
192 name=sync_status.name,
193 manufacturer=sync_status.brand,
194 model=sync_status.model_name,
195 model_id=sync_status.model,
200 name=sync_status.name,
201 manufacturer=sync_status.brand,
202 model=sync_status.model_name,
203 model_id=sync_status.model,
204 via_device=(DOMAIN,
format_mac(sync_status.mac)),
208 """Loop which polls the status of the player."""
212 except PlayerUnreachableError:
214 "Node %s:%s is offline, retrying later", self.
hosthost, self.
portport
216 await asyncio.sleep(NODE_OFFLINE_CHECK_TIMEOUT)
217 except CancelledError:
219 "Stopping the polling of node %s:%s", self.
hosthost, self.
portport
224 "Unexpected error for %s:%s, retrying later", self.
hosthost, self.
portport
226 await asyncio.sleep(NODE_OFFLINE_CHECK_TIMEOUT)
229 """Loop which polls the sync status of the player."""
233 except PlayerUnreachableError:
234 await asyncio.sleep(NODE_OFFLINE_CHECK_TIMEOUT)
235 except CancelledError:
238 await asyncio.sleep(NODE_OFFLINE_CHECK_TIMEOUT)
241 """Start the polling task."""
246 name=f
"bluesound.poll_status_loop_{self.host}:{self.port}",
250 name=f
"bluesound.poll_sync_status_loop_{self.host}:{self.port}",
254 """Stop the polling task."""
260 with suppress(CancelledError):
266 with suppress(CancelledError):
269 self.
hasshass.data[DATA_BLUESOUND].
remove(self)
272 """Update internal status of the entity."""
276 with suppress(PlayerUnreachableError):
281 """Use the poll session to always get the status of the player."""
283 if self.
_status_status
is not None:
284 etag = self.
_status_status.etag
287 status = await self.
_player_player.status(
288 etag=etag, poll_timeout=POLL_TIMEOUT, timeout=POLL_TIMEOUT + 5
296 except PlayerUnreachableError:
302 "Client connection error, marking %s as offline",
308 """Update the internal status."""
313 etag=etag, poll_timeout=POLL_TIMEOUT, timeout=POLL_TIMEOUT + 5
320 if sync_status.master
is not None:
322 master_id = f
"{sync_status.master.ip}:{sync_status.master.port}"
325 for device
in self.
hasshass.data[DATA_BLUESOUND]
326 if device.id == master_id
329 if master_device
and master_id != self.
ididid:
333 _LOGGER.error(
"Master not found %s", master_id)
335 if self.
_master_master
is not None:
338 self.
_is_master_is_master = slaves
is not None
343 """Update Capture sources."""
344 inputs = await self.
_player_player.inputs()
348 """Update Presets."""
349 presets = await self.
_player_player.presets()
353 def state(self) -> MediaPlayerState:
354 """Return the state of the device."""
355 if self.
_status_status
is None:
356 return MediaPlayerState.OFF
359 return MediaPlayerState.IDLE
361 match self.
_status_status.state:
363 return MediaPlayerState.PAUSED
364 case
"stream" |
"play":
365 return MediaPlayerState.PLAYING
367 return MediaPlayerState.IDLE
371 """Title of current playing media."""
375 return self.
_status_status.name
379 """Artist of current playing media (Music track only)."""
380 if self.
_status_status
is None:
384 return self._group_name
386 return self.
_status_status.artist
390 """Artist of current playing media (Music track only)."""
394 return self.
_status_status.album
398 """Image url of current playing media."""
402 url = self.
_status_status.image
407 url = f
"http://{self.host}:{self.port}{url}"
413 """Position of current playing media in seconds."""
418 if self.
_last_status_update_last_status_update
is None or mediastate == MediaPlayerState.IDLE:
421 position = self.
_status_status.seconds
425 if mediastate == MediaPlayerState.PLAYING:
426 position += (dt_util.utcnow() - self.
_last_status_update_last_status_update).total_seconds()
432 """Duration of current playing media in seconds."""
436 duration = self.
_status_status.total_seconds
444 """Last time status was updated."""
449 """Volume level of the media player (0..1)."""
452 if self.
_status_status
is not None:
453 volume = self.
_status_status.volume
464 """Boolean if volume is currently muted."""
467 if self.
_status_status
is not None:
468 mute = self.
_status_status.mute
470 mute = self.
_sync_status_sync_status.mute_volume
is not None
475 def id(self) -> str | None:
476 """Get id of device."""
481 """Return the device name as returned by the device."""
486 """Return the sync status."""
491 """List of available input sources."""
495 sources = [x.text
for x
in self.
_inputs_inputs]
496 sources += [x.name
for x
in self.
_presets_presets]
502 """Name of the current input source."""
506 if self.
_status_status.input_id
is not None:
507 for input_
in self.
_inputs_inputs:
508 if input_.id == self.
_status_status.input_id:
511 for preset
in self.
_presets_presets:
512 if preset.url == self.
_status_status.stream_url:
515 return self.
_status_status.service
519 """Flag of media commands that are supported."""
520 if self.
_status_status
is None:
525 MediaPlayerEntityFeature.VOLUME_STEP
526 | MediaPlayerEntityFeature.VOLUME_SET
527 | MediaPlayerEntityFeature.VOLUME_MUTE
531 MediaPlayerEntityFeature.CLEAR_PLAYLIST
532 | MediaPlayerEntityFeature.BROWSE_MEDIA
535 if not self.
_status_status.indexing:
538 | MediaPlayerEntityFeature.PAUSE
539 | MediaPlayerEntityFeature.PREVIOUS_TRACK
540 | MediaPlayerEntityFeature.NEXT_TRACK
541 | MediaPlayerEntityFeature.PLAY_MEDIA
542 | MediaPlayerEntityFeature.STOP
543 | MediaPlayerEntityFeature.PLAY
544 | MediaPlayerEntityFeature.SELECT_SOURCE
545 | MediaPlayerEntityFeature.SHUFFLE_SET
549 if current_vol
is not None and current_vol >= 0:
552 | MediaPlayerEntityFeature.VOLUME_STEP
553 | MediaPlayerEntityFeature.VOLUME_SET
554 | MediaPlayerEntityFeature.VOLUME_MUTE
557 if self.
_status_status.can_seek:
558 supported = supported | MediaPlayerEntityFeature.SEEK
564 """Return true if player is a coordinator."""
569 """Return true if player is a coordinator."""
574 """Return true if shuffle is active."""
576 if self.
_status_status
is not None:
577 shuffle = self.
_status_status.shuffle
582 """Join the player to a group."""
585 for device
in self.
hasshass.data[DATA_BLUESOUND]
586 if device.entity_id == master
589 if len(master_device) > 0:
590 if self.
ididid == master_device[0].id:
594 "Trying to join player: %s to master: %s",
601 _LOGGER.error(
"Master not found %s", master_device)
605 """List members in group."""
606 attributes: dict[str, Any] = {}
608 attributes = {ATTR_BLUESOUND_GROUP: self.
_group_list_group_list}
610 attributes[ATTR_MASTER] = self.
_is_master_is_master
615 """Rebuild the list of entities in speaker group."""
619 player_entities: list[BluesoundPlayer] = self.
hasshass.data[DATA_BLUESOUND]
621 leader_sync_status: SyncStatus |
None =
None
625 required_id = f
"{self.sync_status.master.ip}:{self.sync_status.master.port}"
626 for x
in player_entities:
627 if x.sync_status.id == required_id:
628 leader_sync_status = x.sync_status
631 if leader_sync_status
is None or leader_sync_status.slaves
is None:
634 follower_ids = [f
"{x.ip}:{x.port}" for x
in leader_sync_status.slaves]
637 for x
in player_entities
638 if x.sync_status.id
in follower_ids
640 follower_names.insert(0, leader_sync_status.name)
641 return follower_names
644 """Unjoin the player from a group."""
645 if self.
_master_master
is None:
648 _LOGGER.debug(
"Trying to unjoin player: %s", self.
ididid)
652 """Add slave to master."""
653 await self.
_player_player.add_slave(slave_device.host, slave_device.port)
656 """Remove slave to master."""
657 await self.
_player_player.remove_slave(slave_device.host, slave_device.port)
660 """Increase sleep time on player."""
661 return await self.
_player_player.sleep_timer()
664 """Clear sleep timer on player."""
667 sleep = await self.
_player_player.sleep_timer()
670 """Enable or disable shuffle mode."""
674 """Select input source."""
679 url: str |
None =
None
680 for input_
in self.
_inputs_inputs:
681 if input_.text == source:
683 for preset
in self.
_presets_presets:
684 if preset.name == source:
690 await self.
_player_player.play_url(url)
693 """Clear players playlist."""
697 await self.
_player_player.clear()
700 """Send media_next command to media player."""
704 await self.
_player_player.skip()
707 """Send media_previous command to media player."""
711 await self.
_player_player.back()
714 """Send media_play command to media player."""
718 await self.
_player_player.play()
721 """Send media_pause command to media player."""
725 await self.
_player_player.pause()
728 """Send stop command."""
732 await self.
_player_player.stop()
735 """Send media_seek command to media player."""
739 await self.
_player_player.play(seek=
int(position))
742 self, media_type: MediaType | str, media_id: str, **kwargs: Any
744 """Send the play_media command to the media player."""
748 if media_source.is_media_source_id(media_id):
749 play_item = await media_source.async_resolve_media(
752 media_id = play_item.url
756 await self.
_player_player.play_url(url)
759 """Volume up the media player."""
764 new_volume =
min(1, new_volume)
768 """Volume down the media player."""
773 new_volume =
max(0, new_volume)
777 """Send volume_up command to media player."""
778 volume =
int(round(volume * 100))
779 volume =
min(100, volume)
780 volume =
max(0, volume)
782 await self.
_player_player.volume(level=volume)
785 """Send mute command to media player."""
786 await self.
_player_player.volume(mute=mute)
790 media_content_type: MediaType | str |
None =
None,
791 media_content_id: str |
None =
None,
793 """Implement the websocket media browsing helper."""
794 return await media_source.async_browse_media(
797 content_filter=
lambda item: item.media_content_type.startswith(
"audio/"),
None async_write_ha_state(self)
str format_unique_id(str mac, int port)
bool remove(self, _T matcher)