Use pyblu library in bluesound (#117257)

* Integrate pypi libraray: pyblu

* Raise PlatformNotReady if _sync_status is not available yet

* Revert "Raise PlatformNotReady if _sync_status is not available yet"

This reverts commit a649a6bccd00cf16f80e40dc169ca8797ed3b6b2.

* Replace 'async with timeout' with parameter in library

* Set timeout back to 10 seconds

* ruff fixes

* Update homeassistant/components/bluesound/media_player.py

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

---------

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
This commit is contained in:
Louis Christ 2024-07-21 12:24:54 +02:00 committed by GitHub
parent fcca475e36
commit f629364dc4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 193 additions and 408 deletions

View File

@ -4,5 +4,5 @@
"codeowners": ["@thrawnarn"], "codeowners": ["@thrawnarn"],
"documentation": "https://www.home-assistant.io/integrations/bluesound", "documentation": "https://www.home-assistant.io/integrations/bluesound",
"iot_class": "local_polling", "iot_class": "local_polling",
"requirements": ["xmltodict==0.13.0"] "requirements": ["pyblu==0.4.0"]
} }

View File

@ -3,18 +3,15 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
from asyncio import CancelledError, timeout from asyncio import CancelledError
from datetime import timedelta from contextlib import suppress
from http import HTTPStatus from datetime import datetime, timedelta
import logging import logging
from typing import Any, NamedTuple from typing import Any, NamedTuple
from urllib import parse
import aiohttp
from aiohttp.client_exceptions import ClientError from aiohttp.client_exceptions import ClientError
from aiohttp.hdrs import CONNECTION, KEEP_ALIVE from pyblu import Input, Player, Preset, Status, SyncStatus
import voluptuous as vol import voluptuous as vol
import xmltodict
from homeassistant.components import media_source from homeassistant.components import media_source
from homeassistant.components.media_player import ( from homeassistant.components.media_player import (
@ -36,6 +33,7 @@ from homeassistant.const import (
EVENT_HOMEASSISTANT_STOP, EVENT_HOMEASSISTANT_STOP,
) )
from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
import homeassistant.helpers.config_validation as cv import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.device_registry import format_mac
@ -109,7 +107,7 @@ SERVICE_TO_METHOD = {
} }
def _add_player(hass, async_add_entities, host, port=None, name=None): def _add_player(hass: HomeAssistant, async_add_entities, host, port=None, name=None):
"""Add Bluesound players.""" """Add Bluesound players."""
@callback @callback
@ -123,7 +121,7 @@ def _add_player(hass, async_add_entities, host, port=None, name=None):
player.start_polling() player.start_polling()
@callback @callback
def _stop_polling(): def _stop_polling(event=None):
"""Stop polling.""" """Stop polling."""
player.stop_polling() player.stop_polling()
@ -213,38 +211,38 @@ class BluesoundPlayer(MediaPlayerEntity):
_attr_media_content_type = MediaType.MUSIC _attr_media_content_type = MediaType.MUSIC
def __init__(self, hass, host, port=None, name=None, init_callback=None): def __init__(
self, hass: HomeAssistant, host, port=None, name=None, init_callback=None
) -> None:
"""Initialize the media player.""" """Initialize the media player."""
self.host = host self.host = host
self._hass = hass self._hass = hass
self.port = port self.port = port
self._polling_session = async_get_clientsession(hass)
self._polling_task = None # The actual polling task. self._polling_task = None # The actual polling task.
self._name = name self._name = name
self._id = None self._id = None
self._capture_items = []
self._services_items = []
self._preset_items = []
self._sync_status = {}
self._status = None
self._last_status_update = None self._last_status_update = None
self._sync_status: SyncStatus | None = None
self._status: Status | None = None
self._inputs: list[Input] = []
self._presets: list[Preset] = []
self._is_online = False self._is_online = False
self._retry_remove = None self._retry_remove = None
self._muted = False self._muted = False
self._master = None self._master: BluesoundPlayer | None = None
self._is_master = False self._is_master = False
self._group_name = None self._group_name = None
self._group_list = [] self._group_list: list[str] = []
self._bluesound_device_name = None self._bluesound_device_name = None
self._player = Player(
host, port, async_get_clientsession(hass), default_timeout=10
)
self._init_callback = init_callback self._init_callback = init_callback
if self.port is None: if self.port is None:
self.port = DEFAULT_PORT self.port = DEFAULT_PORT
class _TimeoutException(Exception):
pass
@staticmethod @staticmethod
def _try_get_index(string, search_string): def _try_get_index(string, search_string):
"""Get the index.""" """Get the index."""
@ -253,28 +251,22 @@ class BluesoundPlayer(MediaPlayerEntity):
except ValueError: except ValueError:
return -1 return -1
async def force_update_sync_status(self, on_updated_cb=None, raise_timeout=False): async def force_update_sync_status(self, on_updated_cb=None) -> bool:
"""Update the internal status.""" """Update the internal status."""
resp = await self.send_bluesound_command( sync_status = await self._player.sync_status()
"SyncStatus", raise_timeout, raise_timeout
)
if not resp: self._sync_status = sync_status
return None
self._sync_status = resp["SyncStatus"].copy()
if not self._name: if not self._name:
self._name = self._sync_status.get("@name", self.host) self._name = sync_status.name if sync_status.name else self.host
if not self._id: if not self._id:
self._id = self._sync_status.get("@id", None) self._id = sync_status.id
if not self._bluesound_device_name: if not self._bluesound_device_name:
self._bluesound_device_name = self._sync_status.get("@name", self.host) self._bluesound_device_name = self._name
if (master := self._sync_status.get("master")) is not None: if sync_status.master is not None:
self._is_master = False self._is_master = False
master_host = master.get("#text") master_id = f"{sync_status.master.ip}:{sync_status.master.port}"
master_port = master.get("@port", "11000")
master_id = f"{master_host}:{master_port}"
master_device = [ master_device = [
device device
for device in self._hass.data[DATA_BLUESOUND] for device in self._hass.data[DATA_BLUESOUND]
@ -289,7 +281,7 @@ class BluesoundPlayer(MediaPlayerEntity):
else: else:
if self._master is not None: if self._master is not None:
self._master = None self._master = None
slaves = self._sync_status.get("slave") slaves = self._sync_status.slaves
self._is_master = slaves is not None self._is_master = slaves is not None
if on_updated_cb: if on_updated_cb:
@ -302,7 +294,7 @@ class BluesoundPlayer(MediaPlayerEntity):
while True: while True:
await self.async_update_status() await self.async_update_status()
except (TimeoutError, ClientError, BluesoundPlayer._TimeoutException): except (TimeoutError, ClientError):
_LOGGER.info("Node %s:%s is offline, retrying later", self.name, self.port) _LOGGER.info("Node %s:%s is offline, retrying later", self.name, self.port)
await asyncio.sleep(NODE_OFFLINE_CHECK_TIMEOUT) await asyncio.sleep(NODE_OFFLINE_CHECK_TIMEOUT)
self.start_polling() self.start_polling()
@ -328,7 +320,7 @@ class BluesoundPlayer(MediaPlayerEntity):
self._retry_remove() self._retry_remove()
self._retry_remove = None self._retry_remove = None
await self.force_update_sync_status(self._init_callback, True) await self.force_update_sync_status(self._init_callback)
except (TimeoutError, ClientError): except (TimeoutError, ClientError):
_LOGGER.info("Node %s:%s is offline, retrying later", self.host, self.port) _LOGGER.info("Node %s:%s is offline, retrying later", self.host, self.port)
self._retry_remove = async_track_time_interval( self._retry_remove = async_track_time_interval(
@ -345,110 +337,48 @@ class BluesoundPlayer(MediaPlayerEntity):
if not self._is_online: if not self._is_online:
return return
await self.async_update_sync_status() with suppress(TimeoutError):
await self.async_update_presets() await self.async_update_sync_status()
await self.async_update_captures() await self.async_update_presets()
await self.async_update_services() await self.async_update_captures()
async def send_bluesound_command(
self, method, raise_timeout=False, allow_offline=False
):
"""Send command to the player."""
if not self._is_online and not allow_offline:
return None
if method[0] == "/":
method = method[1:]
url = f"http://{self.host}:{self.port}/{method}"
_LOGGER.debug("Calling URL: %s", url)
response = None
try:
websession = async_get_clientsession(self._hass)
async with timeout(10):
response = await websession.get(url)
if response.status == HTTPStatus.OK:
result = await response.text()
if result:
data = xmltodict.parse(result)
else:
data = None
elif response.status == 595:
_LOGGER.info("Status 595 returned, treating as timeout")
raise BluesoundPlayer._TimeoutException
else:
_LOGGER.error("Error %s on %s", response.status, url)
return None
except (TimeoutError, aiohttp.ClientError):
if raise_timeout:
_LOGGER.info("Timeout: %s:%s", self.host, self.port)
raise
_LOGGER.debug("Failed communicating: %s:%s", self.host, self.port)
return None
return data
async def async_update_status(self): async def async_update_status(self):
"""Use the poll session to always get the status of the player.""" """Use the poll session to always get the status of the player."""
response = None etag = None
url = "Status"
etag = ""
if self._status is not None: if self._status is not None:
etag = self._status.get("@etag", "") etag = self._status.etag
if etag != "":
url = f"Status?etag={etag}&timeout=120.0"
url = f"http://{self.host}:{self.port}/{url}"
_LOGGER.debug("Calling URL: %s", url)
try: try:
async with timeout(125): status = await self._player.status(etag=etag, poll_timeout=120, timeout=125)
response = await self._polling_session.get(
url, headers={CONNECTION: KEEP_ALIVE}
)
if response.status == HTTPStatus.OK: self._is_online = True
result = await response.text() self._last_status_update = dt_util.utcnow()
self._is_online = True self._status = status
self._last_status_update = dt_util.utcnow()
self._status = xmltodict.parse(result)["status"].copy()
group_name = self._status.get("groupName") group_name = status.group_name
if group_name != self._group_name: if group_name != self._group_name:
_LOGGER.debug("Group name change detected on device: %s", self.id) _LOGGER.debug("Group name change detected on device: %s", self.id)
self._group_name = group_name self._group_name = group_name
# rebuild ordered list of entity_ids that are in the group, master is first # rebuild ordered list of entity_ids that are in the group, master is first
self._group_list = self.rebuild_bluesound_group() self._group_list = self.rebuild_bluesound_group()
# the sleep is needed to make sure that the # the sleep is needed to make sure that the
# devices is synced # devices is synced
await asyncio.sleep(1) await asyncio.sleep(1)
await self.async_trigger_sync_on_all() await self.async_trigger_sync_on_all()
elif self.is_grouped: elif self.is_grouped:
# when player is grouped we need to fetch volume from # when player is grouped we need to fetch volume from
# sync_status. We will force an update if the player is # sync_status. We will force an update if the player is
# grouped this isn't a foolproof solution. A better # grouped this isn't a foolproof solution. A better
# solution would be to fetch sync_status more often when # solution would be to fetch sync_status more often when
# the device is playing. This would solve a lot of # the device is playing. This would solve a lot of
# problems. This change will be done when the # problems. This change will be done when the
# communication is moved to a separate library # communication is moved to a separate library
with suppress(TimeoutError):
await self.force_update_sync_status() await self.force_update_sync_status()
self.async_write_ha_state() self.async_write_ha_state()
elif response.status == 595:
_LOGGER.info("Status 595 returned, treating as timeout")
raise BluesoundPlayer._TimeoutException
else:
_LOGGER.error(
"Error %s on %s. Trying one more time", response.status, url
)
except (TimeoutError, ClientError): except (TimeoutError, ClientError):
self._is_online = False self._is_online = False
self._last_status_update = None self._last_status_update = None
@ -458,9 +388,10 @@ class BluesoundPlayer(MediaPlayerEntity):
raise raise
@property @property
def unique_id(self): def unique_id(self) -> str | None:
"""Return an unique ID.""" """Return an unique ID."""
return f"{format_mac(self._sync_status['@mac'])}-{self.port}" assert self._sync_status is not None
return f"{format_mac(self._sync_status.mac)}-{self.port}"
async def async_trigger_sync_on_all(self): async def async_trigger_sync_on_all(self):
"""Trigger sync status update on all devices.""" """Trigger sync status update on all devices."""
@ -470,95 +401,25 @@ class BluesoundPlayer(MediaPlayerEntity):
await player.force_update_sync_status() await player.force_update_sync_status()
@Throttle(SYNC_STATUS_INTERVAL) @Throttle(SYNC_STATUS_INTERVAL)
async def async_update_sync_status(self, on_updated_cb=None, raise_timeout=False): async def async_update_sync_status(self, on_updated_cb=None):
"""Update sync status.""" """Update sync status."""
await self.force_update_sync_status(on_updated_cb, raise_timeout=False) await self.force_update_sync_status(on_updated_cb)
@Throttle(UPDATE_CAPTURE_INTERVAL) @Throttle(UPDATE_CAPTURE_INTERVAL)
async def async_update_captures(self): async def async_update_captures(self) -> list[Input] | None:
"""Update Capture sources.""" """Update Capture sources."""
resp = await self.send_bluesound_command("RadioBrowse?service=Capture") inputs = await self._player.inputs()
if not resp: self._inputs = inputs
return None
self._capture_items = []
def _create_capture_item(item): return inputs
self._capture_items.append(
{
"title": item.get("@text", ""),
"name": item.get("@text", ""),
"type": item.get("@serviceType", "Capture"),
"image": item.get("@image", ""),
"url": item.get("@URL", ""),
}
)
if "radiotime" in resp and "item" in resp["radiotime"]:
if isinstance(resp["radiotime"]["item"], list):
for item in resp["radiotime"]["item"]:
_create_capture_item(item)
else:
_create_capture_item(resp["radiotime"]["item"])
return self._capture_items
@Throttle(UPDATE_PRESETS_INTERVAL) @Throttle(UPDATE_PRESETS_INTERVAL)
async def async_update_presets(self): async def async_update_presets(self) -> list[Preset] | None:
"""Update Presets.""" """Update Presets."""
resp = await self.send_bluesound_command("Presets") presets = await self._player.presets()
if not resp: self._presets = presets
return None
self._preset_items = []
def _create_preset_item(item): return presets
self._preset_items.append(
{
"title": item.get("@name", ""),
"name": item.get("@name", ""),
"type": "preset",
"image": item.get("@image", ""),
"is_raw_url": True,
"url2": item.get("@url", ""),
"url": f"Preset?id={item.get('@id', '')}",
}
)
if "presets" in resp and "preset" in resp["presets"]:
if isinstance(resp["presets"]["preset"], list):
for item in resp["presets"]["preset"]:
_create_preset_item(item)
else:
_create_preset_item(resp["presets"]["preset"])
return self._preset_items
@Throttle(UPDATE_SERVICES_INTERVAL)
async def async_update_services(self):
"""Update Services."""
resp = await self.send_bluesound_command("Services")
if not resp:
return None
self._services_items = []
def _create_service_item(item):
self._services_items.append(
{
"title": item.get("@displayname", ""),
"name": item.get("@name", ""),
"type": item.get("@type", ""),
"image": item.get("@icon", ""),
"url": item.get("@name", ""),
}
)
if "services" in resp and "service" in resp["services"]:
if isinstance(resp["services"]["service"], list):
for item in resp["services"]["service"]:
_create_service_item(item)
else:
_create_service_item(resp["services"]["service"])
return self._services_items
@property @property
def state(self) -> MediaPlayerState: def state(self) -> MediaPlayerState:
@ -569,7 +430,7 @@ class BluesoundPlayer(MediaPlayerEntity):
if self.is_grouped and not self.is_master: if self.is_grouped and not self.is_master:
return MediaPlayerState.IDLE return MediaPlayerState.IDLE
status = self._status.get("state") status = self._status.state
if status in ("pause", "stop"): if status in ("pause", "stop"):
return MediaPlayerState.PAUSED return MediaPlayerState.PAUSED
if status in ("stream", "play"): if status in ("stream", "play"):
@ -577,15 +438,15 @@ class BluesoundPlayer(MediaPlayerEntity):
return MediaPlayerState.IDLE return MediaPlayerState.IDLE
@property @property
def media_title(self): def media_title(self) -> str | None:
"""Title of current playing media.""" """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_master):
return None return None
return self._status.get("title1") return self._status.name
@property @property
def media_artist(self): def media_artist(self) -> str | None:
"""Artist of current playing media (Music track only).""" """Artist of current playing media (Music track only)."""
if self._status is None: if self._status is None:
return None return None
@ -593,35 +454,33 @@ class BluesoundPlayer(MediaPlayerEntity):
if self.is_grouped and not self.is_master: if self.is_grouped and not self.is_master:
return self._group_name return self._group_name
if not (artist := self._status.get("artist")): return self._status.artist
artist = self._status.get("title2")
return artist
@property @property
def media_album_name(self): def media_album_name(self) -> str | None:
"""Artist of current playing media (Music track only).""" """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_master):
return None return None
if not (album := self._status.get("album")): return self._status.album
album = self._status.get("title3")
return album
@property @property
def media_image_url(self): def media_image_url(self) -> str | None:
"""Image url of current playing media.""" """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_master):
return None return None
if not (url := self._status.get("image")): url = self._status.image
if url is None:
return None return None
if url[0] == "/": if url[0] == "/":
url = f"http://{self.host}:{self.port}{url}" url = f"http://{self.host}:{self.port}{url}"
return url return url
@property @property
def media_position(self): def media_position(self) -> int | None:
"""Position of current playing media in seconds.""" """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_master):
return None return None
@ -630,154 +489,101 @@ class BluesoundPlayer(MediaPlayerEntity):
if self._last_status_update is None or mediastate == MediaPlayerState.IDLE: if self._last_status_update is None or mediastate == MediaPlayerState.IDLE:
return None return None
if (position := self._status.get("secs")) is None: position = self._status.seconds
if position is None:
return None return None
position = float(position)
if mediastate == MediaPlayerState.PLAYING: if mediastate == MediaPlayerState.PLAYING:
position += (dt_util.utcnow() - self._last_status_update).total_seconds() position += (dt_util.utcnow() - self._last_status_update).total_seconds()
return position return int(position)
@property @property
def media_duration(self): def media_duration(self) -> int | None:
"""Duration of current playing media in seconds.""" """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_master):
return None return None
if (duration := self._status.get("totlen")) is None: duration = self._status.total_seconds
if duration is None:
return None return None
return float(duration)
return duration
@property @property
def media_position_updated_at(self): def media_position_updated_at(self) -> datetime | None:
"""Last time status was updated.""" """Last time status was updated."""
return self._last_status_update return self._last_status_update
@property @property
def volume_level(self): def volume_level(self) -> float | None:
"""Volume level of the media player (0..1).""" """Volume level of the media player (0..1)."""
volume = self._status.get("volume") volume = None
if self.is_grouped:
volume = self._sync_status.get("@volume")
if volume is not None: if self._status is not None:
return int(volume) / 100 volume = self._status.volume
return None if self.is_grouped and self._sync_status is not None:
volume = self._sync_status.volume
if volume is None:
return None
return volume / 100
@property @property
def is_volume_muted(self): def is_volume_muted(self) -> bool:
"""Boolean if volume is currently muted.""" """Boolean if volume is currently muted."""
mute = self._status.get("mute") mute = False
if self.is_grouped:
mute = self._sync_status.get("@mute") if self._status is not None:
mute = self._status.mute
if self.is_grouped and self._sync_status is not None:
mute = self._sync_status.mute_volume is not None
if mute is not None:
mute = bool(int(mute))
return mute return mute
@property @property
def id(self): def id(self) -> str | None:
"""Get id of device.""" """Get id of device."""
return self._id return self._id
@property @property
def name(self): def name(self) -> str | None:
"""Return the name of the device.""" """Return the name of the device."""
return self._name return self._name
@property @property
def bluesound_device_name(self): def bluesound_device_name(self) -> str | None:
"""Return the device name as returned by the device.""" """Return the device name as returned by the device."""
return self._bluesound_device_name return self._bluesound_device_name
@property @property
def source_list(self): def source_list(self) -> list[str] | None:
"""List of available input sources.""" """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_master):
return None return None
sources = [source["title"] for source in self._preset_items] sources = [x.text for x in self._inputs]
sources += [x.name for x in self._presets]
sources.extend(
source["title"]
for source in self._services_items
if source["type"] in ("LocalMusic", "RadioService")
)
sources.extend(source["title"] for source in self._capture_items)
return sources return sources
@property @property
def source(self): def source(self) -> str | None:
"""Name of the current input source.""" """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_master):
return None return None
if (current_service := self._status.get("service", "")) == "": if self._status.input_id is not None:
return "" for input_ in self._inputs:
stream_url = self._status.get("streamUrl", "") if input_.id == self._status.input_id:
return input_.text
if self._status.get("is_preset", "") == "1" and stream_url != "": for preset in self._presets:
# This check doesn't work with all presets, for example playlists. if preset.url == self._status.stream_url:
# But it works with radio service_items will catch playlists. return preset.name
items = [
x
for x in self._preset_items
if "url2" in x and parse.unquote(x["url2"]) == stream_url
]
if items:
return items[0]["title"]
# This could be a bit difficult to detect. Bluetooth could be named return self._status.service
# different things and there is not any way to match chooses in
# capture list to current playing. It's a bit of guesswork.
# This method will be needing some tweaking over time.
title = self._status.get("title1", "").lower()
if title == "bluetooth" or stream_url == "Capture:hw:2,0/44100/16/2":
items = [
x
for x in self._capture_items
if x["url"] == "Capture%3Abluez%3Abluetooth"
]
if items:
return items[0]["title"]
items = [x for x in self._capture_items if x["url"] == stream_url]
if items:
return items[0]["title"]
if stream_url[:8] == "Capture:":
stream_url = stream_url[8:]
idx = BluesoundPlayer._try_get_index(stream_url, ":")
if idx > 0:
stream_url = stream_url[:idx]
for item in self._capture_items:
url = parse.unquote(item["url"])
if url[:8] == "Capture:":
url = url[8:]
idx = BluesoundPlayer._try_get_index(url, ":")
if idx > 0:
url = url[:idx]
if url.lower() == stream_url.lower():
return item["title"]
items = [x for x in self._capture_items if x["name"] == current_service]
if items:
return items[0]["title"]
items = [x for x in self._services_items if x["name"] == current_service]
if items:
return items[0]["title"]
if self._status.get("streamUrl", "") != "":
_LOGGER.debug(
"Couldn't find source of stream URL: %s",
self._status.get("streamUrl", ""),
)
return None
@property @property
def supported_features(self) -> MediaPlayerEntityFeature: def supported_features(self) -> MediaPlayerEntityFeature:
@ -797,7 +603,7 @@ class BluesoundPlayer(MediaPlayerEntity):
| MediaPlayerEntityFeature.BROWSE_MEDIA | MediaPlayerEntityFeature.BROWSE_MEDIA
) )
if self._status.get("indexing", "0") == "0": if not self._status.indexing:
supported = ( supported = (
supported supported
| MediaPlayerEntityFeature.PAUSE | MediaPlayerEntityFeature.PAUSE
@ -819,25 +625,29 @@ class BluesoundPlayer(MediaPlayerEntity):
| MediaPlayerEntityFeature.VOLUME_MUTE | MediaPlayerEntityFeature.VOLUME_MUTE
) )
if self._status.get("canSeek", "") == "1": if self._status.can_seek:
supported = supported | MediaPlayerEntityFeature.SEEK supported = supported | MediaPlayerEntityFeature.SEEK
return supported return supported
@property @property
def is_master(self): def is_master(self) -> bool:
"""Return true if player is a coordinator.""" """Return true if player is a coordinator."""
return self._is_master return self._is_master
@property @property
def is_grouped(self): def is_grouped(self) -> bool:
"""Return true if player is a coordinator.""" """Return true if player is a coordinator."""
return self._master is not None or self._is_master return self._master is not None or self._is_master
@property @property
def shuffle(self): def shuffle(self) -> bool:
"""Return true if shuffle is active.""" """Return true if shuffle is active."""
return self._status.get("shuffle", "0") == "1" shuffle = False
if self._status is not None:
shuffle = self._status.shuffle
return shuffle
async def async_join(self, master): async def async_join(self, master):
"""Join the player to a group.""" """Join the player to a group."""
@ -847,7 +657,10 @@ class BluesoundPlayer(MediaPlayerEntity):
if device.entity_id == master if device.entity_id == master
] ]
if master_device: if len(master_device) > 0:
if self.id == master_device[0].id:
raise ServiceValidationError("Cannot join player to itself")
_LOGGER.debug( _LOGGER.debug(
"Trying to join player: %s to master: %s", "Trying to join player: %s to master: %s",
self.id, self.id,
@ -859,9 +672,9 @@ class BluesoundPlayer(MediaPlayerEntity):
_LOGGER.error("Master not found %s", master_device) _LOGGER.error("Master not found %s", master_device)
@property @property
def extra_state_attributes(self): def extra_state_attributes(self) -> dict[str, Any] | None:
"""List members in group.""" """List members in group."""
attributes = {} attributes: dict[str, Any] = {}
if self._group_list: if self._group_list:
attributes = {ATTR_BLUESOUND_GROUP: self._group_list} attributes = {ATTR_BLUESOUND_GROUP: self._group_list}
@ -869,10 +682,10 @@ class BluesoundPlayer(MediaPlayerEntity):
return attributes return attributes
def rebuild_bluesound_group(self): def rebuild_bluesound_group(self) -> list[str]:
"""Rebuild the list of entities in speaker group.""" """Rebuild the list of entities in speaker group."""
if self._group_name is None: if self._group_name is None:
return None return []
device_group = self._group_name.split("+") device_group = self._group_name.split("+")
@ -895,121 +708,92 @@ class BluesoundPlayer(MediaPlayerEntity):
_LOGGER.debug("Trying to unjoin player: %s", self.id) _LOGGER.debug("Trying to unjoin player: %s", self.id)
await self._master.async_remove_slave(self) await self._master.async_remove_slave(self)
async def async_add_slave(self, slave_device): async def async_add_slave(self, slave_device: BluesoundPlayer):
"""Add slave to master.""" """Add slave to master."""
return await self.send_bluesound_command( await self._player.add_slave(slave_device.host, slave_device.port)
f"/AddSlave?slave={slave_device.host}&port={slave_device.port}"
)
async def async_remove_slave(self, slave_device): async def async_remove_slave(self, slave_device: BluesoundPlayer):
"""Remove slave to master.""" """Remove slave to master."""
return await self.send_bluesound_command( await self._player.remove_slave(slave_device.host, slave_device.port)
f"/RemoveSlave?slave={slave_device.host}&port={slave_device.port}"
)
async def async_increase_timer(self): async def async_increase_timer(self) -> int:
"""Increase sleep time on player.""" """Increase sleep time on player."""
sleep_time = await self.send_bluesound_command("/Sleep") return await self._player.sleep_timer()
if sleep_time is None:
_LOGGER.error("Error while increasing sleep time on player: %s", self.id)
return 0
return int(sleep_time.get("sleep", "0"))
async def async_clear_timer(self): async def async_clear_timer(self):
"""Clear sleep timer on player.""" """Clear sleep timer on player."""
sleep = 1 sleep = 1
while sleep > 0: while sleep > 0:
sleep = await self.async_increase_timer() sleep = await self._player.sleep_timer()
async def async_set_shuffle(self, shuffle: bool) -> None: async def async_set_shuffle(self, shuffle: bool) -> None:
"""Enable or disable shuffle mode.""" """Enable or disable shuffle mode."""
value = "1" if shuffle else "0" await self._player.shuffle(shuffle)
return await self.send_bluesound_command(f"/Shuffle?state={value}")
async def async_select_source(self, source: str) -> None: async def async_select_source(self, source: str) -> None:
"""Select input source.""" """Select input source."""
if self.is_grouped and not self.is_master: if self.is_grouped and not self.is_master:
return return
items = [x for x in self._preset_items if x["title"] == source] # presets and inputs might have the same name; presets have priority
url: str | None = None
for input_ in self._inputs:
if input_.text == source:
url = input_.url
for preset in self._presets:
if preset.name == source:
url = preset.url
if not items: await self._player.play_url(url)
items = [x for x in self._services_items if x["title"] == source]
if not items:
items = [x for x in self._capture_items if x["title"] == source]
if not items:
return
selected_source = items[0]
url = f"Play?url={selected_source['url']}&preset_id&image={selected_source['image']}"
if selected_source.get("is_raw_url"):
url = selected_source["url"]
await self.send_bluesound_command(url)
async def async_clear_playlist(self) -> None: async def async_clear_playlist(self) -> None:
"""Clear players playlist.""" """Clear players playlist."""
if self.is_grouped and not self.is_master: if self.is_grouped and not self.is_master:
return return
await self.send_bluesound_command("Clear") await self._player.clear()
async def async_media_next_track(self) -> None: async def async_media_next_track(self) -> None:
"""Send media_next command to media player.""" """Send media_next command to media player."""
if self.is_grouped and not self.is_master: if self.is_grouped and not self.is_master:
return return
cmd = "Skip" await self._player.skip()
if self._status and "actions" in self._status:
for action in self._status["actions"]["action"]:
if "@name" in action and "@url" in action and action["@name"] == "skip":
cmd = action["@url"]
await self.send_bluesound_command(cmd)
async def async_media_previous_track(self) -> None: async def async_media_previous_track(self) -> None:
"""Send media_previous command to media player.""" """Send media_previous command to media player."""
if self.is_grouped and not self.is_master: if self.is_grouped and not self.is_master:
return return
cmd = "Back" await self._player.back()
if self._status and "actions" in self._status:
for action in self._status["actions"]["action"]:
if "@name" in action and "@url" in action and action["@name"] == "back":
cmd = action["@url"]
await self.send_bluesound_command(cmd)
async def async_media_play(self) -> None: async def async_media_play(self) -> None:
"""Send media_play command to media player.""" """Send media_play command to media player."""
if self.is_grouped and not self.is_master: if self.is_grouped and not self.is_master:
return return
await self.send_bluesound_command("Play") await self._player.play()
async def async_media_pause(self) -> None: async def async_media_pause(self) -> None:
"""Send media_pause command to media player.""" """Send media_pause command to media player."""
if self.is_grouped and not self.is_master: if self.is_grouped and not self.is_master:
return return
await self.send_bluesound_command("Pause") await self._player.pause()
async def async_media_stop(self) -> None: async def async_media_stop(self) -> None:
"""Send stop command.""" """Send stop command."""
if self.is_grouped and not self.is_master: if self.is_grouped and not self.is_master:
return return
await self.send_bluesound_command("Pause") await self._player.stop()
async def async_media_seek(self, position: float) -> None: async def async_media_seek(self, position: float) -> None:
"""Send media_seek command to media player.""" """Send media_seek command to media player."""
if self.is_grouped and not self.is_master: if self.is_grouped and not self.is_master:
return return
await self.send_bluesound_command(f"Play?seek={float(position)}") await self._player.play(seek=int(position))
async def async_play_media( async def async_play_media(
self, media_type: MediaType | str, media_id: str, **kwargs: Any self, media_type: MediaType | str, media_id: str, **kwargs: Any
@ -1024,39 +808,39 @@ class BluesoundPlayer(MediaPlayerEntity):
) )
media_id = play_item.url media_id = play_item.url
media_id = async_process_play_media_url(self.hass, media_id) url = async_process_play_media_url(self.hass, media_id)
url = f"Play?url={media_id}" await self._player.play_url(url)
await self.send_bluesound_command(url)
async def async_volume_up(self) -> None: async def async_volume_up(self) -> None:
"""Volume up the media player.""" """Volume up the media player."""
current_vol = self.volume_level if self.volume_level is None:
if not current_vol or current_vol >= 1: return None
return
await self.async_set_volume_level(current_vol + 0.01) new_volume = self.volume_level + 0.01
new_volume = min(1, new_volume)
return await self.async_set_volume_level(new_volume)
async def async_volume_down(self) -> None: async def async_volume_down(self) -> None:
"""Volume down the media player.""" """Volume down the media player."""
current_vol = self.volume_level if self.volume_level is None:
if not current_vol or current_vol <= 0: return None
return
await self.async_set_volume_level(current_vol - 0.01) new_volume = self.volume_level - 0.01
new_volume = max(0, new_volume)
return await self.async_set_volume_level(new_volume)
async def async_set_volume_level(self, volume: float) -> None: async def async_set_volume_level(self, volume: float) -> None:
"""Send volume_up command to media player.""" """Send volume_up command to media player."""
if volume < 0: volume = int(volume * 100)
volume = 0 volume = min(100, volume)
elif volume > 1: volume = max(0, volume)
volume = 1
await self.send_bluesound_command(f"Volume?level={float(volume) * 100}") await self._player.volume(level=volume)
async def async_mute_volume(self, mute: bool) -> None: async def async_mute_volume(self, mute: bool) -> None:
"""Send mute command to media player.""" """Send mute command to media player."""
if mute: await self._player.volume(mute=mute)
await self.send_bluesound_command("Volume?mute=1")
await self.send_bluesound_command("Volume?mute=0")
async def async_browse_media( async def async_browse_media(
self, self,

View File

@ -1740,6 +1740,9 @@ pybbox==0.0.5-alpha
# homeassistant.components.blackbird # homeassistant.components.blackbird
pyblackbird==0.6 pyblackbird==0.6
# homeassistant.components.bluesound
pyblu==0.4.0
# homeassistant.components.neato # homeassistant.components.neato
pybotvac==0.0.25 pybotvac==0.0.25
@ -2920,7 +2923,6 @@ xknx==2.12.2
# homeassistant.components.knx # homeassistant.components.knx
xknxproject==3.7.1 xknxproject==3.7.1
# homeassistant.components.bluesound
# homeassistant.components.fritz # homeassistant.components.fritz
# homeassistant.components.rest # homeassistant.components.rest
# homeassistant.components.startca # homeassistant.components.startca

View File

@ -2294,7 +2294,6 @@ xknx==2.12.2
# homeassistant.components.knx # homeassistant.components.knx
xknxproject==3.7.1 xknxproject==3.7.1
# homeassistant.components.bluesound
# homeassistant.components.fritz # homeassistant.components.fritz
# homeassistant.components.rest # homeassistant.components.rest
# homeassistant.components.startca # homeassistant.components.startca