Add DataUpdateCoordinator to bluesound integration (#135125)

This commit is contained in:
Louis Christ 2025-01-08 22:09:59 +01:00 committed by GitHub
parent d06cd1ad3b
commit acbd501ede
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 222 additions and 185 deletions

View File

@ -14,10 +14,13 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.typing import ConfigType
from .const import DOMAIN
from .coordinator import BluesoundCoordinator
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
PLATFORMS = [Platform.MEDIA_PLAYER]
PLATFORMS = [
Platform.MEDIA_PLAYER,
]
@dataclass
@ -26,6 +29,7 @@ class BluesoundRuntimeData:
player: Player
sync_status: SyncStatus
coordinator: BluesoundCoordinator
type BluesoundConfigEntry = ConfigEntry[BluesoundRuntimeData]
@ -33,9 +37,6 @@ type BluesoundConfigEntry = ConfigEntry[BluesoundRuntimeData]
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Bluesound."""
if DOMAIN not in hass.data:
hass.data[DOMAIN] = []
return True
@ -46,13 +47,16 @@ async def async_setup_entry(
host = config_entry.data[CONF_HOST]
port = config_entry.data[CONF_PORT]
session = async_get_clientsession(hass)
async with Player(host, port, session=session, default_timeout=10) as player:
try:
sync_status = await player.sync_status(timeout=1)
except PlayerUnreachableError as ex:
raise ConfigEntryNotReady(f"Error connecting to {host}:{port}") from ex
player = Player(host, port, session=session, default_timeout=10)
try:
sync_status = await player.sync_status(timeout=1)
except PlayerUnreachableError as ex:
raise ConfigEntryNotReady(f"Error connecting to {host}:{port}") from ex
config_entry.runtime_data = BluesoundRuntimeData(player, sync_status)
coordinator = BluesoundCoordinator(hass, player, sync_status)
await coordinator.async_config_entry_first_refresh()
config_entry.runtime_data = BluesoundRuntimeData(player, sync_status, coordinator)
await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)

View File

@ -0,0 +1,160 @@
"""Define a base coordinator for Bluesound entities."""
from __future__ import annotations
import asyncio
from collections.abc import Callable, Coroutine
import contextlib
from dataclasses import dataclass, replace
from datetime import timedelta
import logging
from pyblu import Input, Player, Preset, Status, SyncStatus
from pyblu.errors import PlayerUnreachableError
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
NODE_OFFLINE_CHECK_TIMEOUT = timedelta(minutes=3)
PRESET_AND_INPUTS_INTERVAL = timedelta(minutes=15)
@dataclass
class BluesoundData:
"""Define a class to hold Bluesound data."""
sync_status: SyncStatus
status: Status
presets: list[Preset]
inputs: list[Input]
def cancel_task(task: asyncio.Task) -> Callable[[], Coroutine[None, None, None]]:
"""Cancel a task."""
async def _cancel_task() -> None:
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await task
return _cancel_task
class BluesoundCoordinator(DataUpdateCoordinator[BluesoundData]):
"""Define an object to hold Bluesound data."""
def __init__(
self, hass: HomeAssistant, player: Player, sync_status: SyncStatus
) -> None:
"""Initialize."""
self.player = player
self._inital_sync_status = sync_status
super().__init__(
hass,
logger=_LOGGER,
name=sync_status.name,
)
async def _async_setup(self) -> None:
assert self.config_entry is not None
preset = await self.player.presets()
inputs = await self.player.inputs()
status = await self.player.status()
self.async_set_updated_data(
BluesoundData(
sync_status=self._inital_sync_status,
status=status,
presets=preset,
inputs=inputs,
)
)
status_loop_task = self.hass.async_create_background_task(
self._poll_status_loop(),
name=f"bluesound.poll_status_loop_{self.data.sync_status.id}",
)
self.config_entry.async_on_unload(cancel_task(status_loop_task))
sync_status_loop_task = self.hass.async_create_background_task(
self._poll_sync_status_loop(),
name=f"bluesound.poll_sync_status_loop_{self.data.sync_status.id}",
)
self.config_entry.async_on_unload(cancel_task(sync_status_loop_task))
presets_and_inputs_loop_task = self.hass.async_create_background_task(
self._poll_presets_and_inputs_loop(),
name=f"bluesound.poll_presets_and_inputs_loop_{self.data.sync_status.id}",
)
self.config_entry.async_on_unload(cancel_task(presets_and_inputs_loop_task))
async def _async_update_data(self) -> BluesoundData:
return self.data
async def _poll_presets_and_inputs_loop(self) -> None:
while True:
await asyncio.sleep(PRESET_AND_INPUTS_INTERVAL.total_seconds())
try:
preset = await self.player.presets()
inputs = await self.player.inputs()
self.async_set_updated_data(
replace(
self.data,
presets=preset,
inputs=inputs,
)
)
except PlayerUnreachableError as ex:
self.async_set_update_error(ex)
except asyncio.CancelledError:
return
except Exception as ex: # noqa: BLE001 - this loop should never stop
self.async_set_update_error(ex)
async def _poll_status_loop(self) -> None:
"""Loop which polls the status of the player."""
while True:
try:
status = await self.player.status(
etag=self.data.status.etag, poll_timeout=120, timeout=125
)
self.async_set_updated_data(
replace(
self.data,
status=status,
)
)
except PlayerUnreachableError as ex:
self.async_set_update_error(ex)
await asyncio.sleep(NODE_OFFLINE_CHECK_TIMEOUT.total_seconds())
except asyncio.CancelledError:
return
except Exception as ex: # noqa: BLE001 - this loop should never stop
self.async_set_update_error(ex)
await asyncio.sleep(NODE_OFFLINE_CHECK_TIMEOUT.total_seconds())
async def _poll_sync_status_loop(self) -> None:
"""Loop which polls the sync status of the player."""
while True:
try:
sync_status = await self.player.sync_status(
etag=self.data.sync_status.etag, poll_timeout=120, timeout=125
)
self.async_set_updated_data(
replace(
self.data,
sync_status=sync_status,
)
)
except PlayerUnreachableError as ex:
self.async_set_update_error(ex)
await asyncio.sleep(NODE_OFFLINE_CHECK_TIMEOUT.total_seconds())
except asyncio.CancelledError:
raise
except Exception as ex: # noqa: BLE001 - this loop should never stop
self.async_set_update_error(ex)
await asyncio.sleep(NODE_OFFLINE_CHECK_TIMEOUT.total_seconds())

View File

@ -2,15 +2,12 @@
from __future__ import annotations
import asyncio
from asyncio import CancelledError, Task
from contextlib import suppress
from asyncio import Task
from datetime import datetime, timedelta
import logging
from typing import TYPE_CHECKING, Any
from pyblu import Input, Player, Preset, Status, SyncStatus
from pyblu.errors import PlayerUnreachableError
import voluptuous as vol
from homeassistant.components import media_source
@ -23,7 +20,7 @@ from homeassistant.components.media_player import (
async_process_play_media_url,
)
from homeassistant.const import CONF_HOST, CONF_PORT
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import config_validation as cv, entity_platform
from homeassistant.helpers.device_registry import (
@ -36,9 +33,11 @@ from homeassistant.helpers.dispatcher import (
async_dispatcher_send,
)
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
import homeassistant.util.dt as dt_util
from .const import ATTR_BLUESOUND_GROUP, ATTR_MASTER, DOMAIN
from .coordinator import BluesoundCoordinator
from .utils import dispatcher_join_signal, dispatcher_unjoin_signal, format_unique_id
if TYPE_CHECKING:
@ -56,11 +55,6 @@ SERVICE_JOIN = "join"
SERVICE_SET_TIMER = "set_sleep_timer"
SERVICE_UNJOIN = "unjoin"
NODE_OFFLINE_CHECK_TIMEOUT = 180
NODE_RETRY_INITIATION = timedelta(minutes=3)
SYNC_STATUS_INTERVAL = timedelta(minutes=5)
POLL_TIMEOUT = 120
@ -71,10 +65,10 @@ async def async_setup_entry(
) -> None:
"""Set up the Bluesound entry."""
bluesound_player = BluesoundPlayer(
config_entry.runtime_data.coordinator,
config_entry.data[CONF_HOST],
config_entry.data[CONF_PORT],
config_entry.runtime_data.player,
config_entry.runtime_data.sync_status,
)
platform = entity_platform.async_get_current_platform()
@ -89,11 +83,10 @@ async def async_setup_entry(
)
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)
class BluesoundPlayer(MediaPlayerEntity):
class BluesoundPlayer(CoordinatorEntity[BluesoundCoordinator], MediaPlayerEntity):
"""Representation of a Bluesound Player."""
_attr_media_content_type = MediaType.MUSIC
@ -102,12 +95,15 @@ class BluesoundPlayer(MediaPlayerEntity):
def __init__(
self,
coordinator: BluesoundCoordinator,
host: str,
port: int,
player: Player,
sync_status: SyncStatus,
) -> None:
"""Initialize the media player."""
super().__init__(coordinator)
sync_status = coordinator.data.sync_status
self.host = host
self.port = port
self._poll_status_loop_task: Task[None] | None = None
@ -115,15 +111,14 @@ class BluesoundPlayer(MediaPlayerEntity):
self._id = sync_status.id
self._last_status_update: datetime | None = None
self._sync_status = sync_status
self._status: Status | None = None
self._inputs: list[Input] = []
self._presets: list[Preset] = []
self._status: Status = coordinator.data.status
self._inputs: list[Input] = coordinator.data.inputs
self._presets: list[Preset] = coordinator.data.presets
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._last_status_update = dt_util.utcnow()
self._attr_unique_id = format_unique_id(sync_status.mac, port)
# there should always be one player with the default port per mac
@ -146,52 +141,10 @@ class BluesoundPlayer(MediaPlayerEntity):
via_device=(DOMAIN, format_mac(sync_status.mac)),
)
async def _poll_status_loop(self) -> None:
"""Loop which polls the status of the player."""
while True:
try:
await self.async_update_status()
except PlayerUnreachableError:
_LOGGER.error(
"Node %s:%s is offline, retrying later", self.host, self.port
)
await asyncio.sleep(NODE_OFFLINE_CHECK_TIMEOUT)
except CancelledError:
_LOGGER.debug(
"Stopping the polling of node %s:%s", self.host, self.port
)
return
except: # noqa: E722 - this loop should never stop
_LOGGER.exception(
"Unexpected error for %s:%s, retrying later", self.host, self.port
)
await asyncio.sleep(NODE_OFFLINE_CHECK_TIMEOUT)
async def _poll_sync_status_loop(self) -> None:
"""Loop which polls the sync status of the player."""
while True:
try:
await self.update_sync_status()
except PlayerUnreachableError:
await asyncio.sleep(NODE_OFFLINE_CHECK_TIMEOUT)
except CancelledError:
raise
except: # noqa: E722 - all errors must be caught for this loop
await asyncio.sleep(NODE_OFFLINE_CHECK_TIMEOUT)
async def async_added_to_hass(self) -> None:
"""Start the polling task."""
await super().async_added_to_hass()
self._poll_status_loop_task = self.hass.async_create_background_task(
self._poll_status_loop(),
name=f"bluesound.poll_status_loop_{self.host}:{self.port}",
)
self._poll_sync_status_loop_task = self.hass.async_create_background_task(
self._poll_sync_status_loop(),
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(
@ -212,105 +165,24 @@ class BluesoundPlayer(MediaPlayerEntity):
"""Stop the polling task."""
await super().async_will_remove_from_hass()
assert self._poll_status_loop_task is not None
if self._poll_status_loop_task.cancel():
# the sleeps in _poll_loop will raise CancelledError
with suppress(CancelledError):
await self._poll_status_loop_task
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
self._sync_status = self.coordinator.data.sync_status
self._status = self.coordinator.data.status
self._inputs = self.coordinator.data.inputs
self._presets = self.coordinator.data.presets
assert self._poll_sync_status_loop_task is not None
if self._poll_sync_status_loop_task.cancel():
# the sleeps in _poll_sync_status_loop will raise CancelledError
with suppress(CancelledError):
await self._poll_sync_status_loop_task
self.hass.data[DATA_BLUESOUND].remove(self)
async def async_update(self) -> None:
"""Update internal status of the entity."""
if not self.available:
return
with suppress(PlayerUnreachableError):
await self.async_update_presets()
await self.async_update_captures()
async def async_update_status(self) -> None:
"""Use the poll session to always get the status of the player."""
etag = None
if self._status is not None:
etag = self._status.etag
try:
status = await self._player.status(
etag=etag, poll_timeout=POLL_TIMEOUT, timeout=POLL_TIMEOUT + 5
)
self._attr_available = True
self._last_status_update = dt_util.utcnow()
self._status = status
self.async_write_ha_state()
except PlayerUnreachableError:
self._attr_available = False
self._last_status_update = None
self._status = None
self.async_write_ha_state()
_LOGGER.error(
"Client connection error, marking %s as offline",
self._bluesound_device_name,
)
raise
async def update_sync_status(self) -> None:
"""Update the internal status."""
etag = None
if self._sync_status:
etag = self._sync_status.etag
sync_status = await self._player.sync_status(
etag=etag, poll_timeout=POLL_TIMEOUT, timeout=POLL_TIMEOUT + 5
)
self._sync_status = sync_status
self._last_status_update = dt_util.utcnow()
self._group_list = self.rebuild_bluesound_group()
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 == leader_id
]
if leader_device and leader_id != self.id:
self._leader = leader_device[0]
else:
self._leader = None
_LOGGER.error("Leader not found %s", leader_id)
else:
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()
async def async_update_captures(self) -> None:
"""Update Capture sources."""
inputs = await self._player.inputs()
self._inputs = inputs
async def async_update_presets(self) -> None:
"""Update Presets."""
presets = await self._player.presets()
self._presets = presets
@property
def state(self) -> MediaPlayerState:
"""Return the state of the device."""
if self._status is None:
if self.available is False:
return MediaPlayerState.OFF
if self.is_grouped and not self.is_leader:
@ -327,7 +199,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_leader):
if self.available is False or (self.is_grouped and not self.is_leader):
return None
return self._status.name
@ -335,7 +207,7 @@ class BluesoundPlayer(MediaPlayerEntity):
@property
def media_artist(self) -> str | None:
"""Artist of current playing media (Music track only)."""
if self._status is None:
if self.available is False:
return None
if self.is_grouped and not self.is_leader:
@ -346,7 +218,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_leader):
if self.available is False or (self.is_grouped and not self.is_leader):
return None
return self._status.album
@ -354,7 +226,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_leader):
if self.available is False or (self.is_grouped and not self.is_leader):
return None
url = self._status.image
@ -369,7 +241,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_leader):
if self.available is False or (self.is_grouped and not self.is_leader):
return None
mediastate = self.state
@ -388,7 +260,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_leader):
if self.available is False or (self.is_grouped and not self.is_leader):
return None
duration = self._status.total_seconds
@ -405,16 +277,11 @@ class BluesoundPlayer(MediaPlayerEntity):
@property
def volume_level(self) -> float | None:
"""Volume level of the media player (0..1)."""
volume = None
volume = self._status.volume
if self._status is not None:
volume = self._status.volume
if self.is_grouped:
volume = self._sync_status.volume
if volume is None:
return None
return volume / 100
@property
@ -447,7 +314,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_leader):
if self.available is False or (self.is_grouped and not self.is_leader):
return None
sources = [x.text for x in self._inputs]
@ -458,7 +325,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_leader):
if self.available is False or (self.is_grouped and not self.is_leader):
return None
if self._status.input_id is not None:
@ -475,7 +342,7 @@ class BluesoundPlayer(MediaPlayerEntity):
@property
def supported_features(self) -> MediaPlayerEntityFeature:
"""Flag of media commands that are supported."""
if self._status is None:
if self.available is False:
return MediaPlayerEntityFeature(0)
if self.is_grouped and not self.is_leader:
@ -577,16 +444,21 @@ class BluesoundPlayer(MediaPlayerEntity):
if self.sync_status.leader is None and self.sync_status.followers is None:
return []
player_entities: list[BluesoundPlayer] = self.hass.data[DATA_BLUESOUND]
config_entries: list[BluesoundConfigEntry] = (
self.hass.config_entries.async_entries(DOMAIN)
)
sync_status_list = [
x.runtime_data.coordinator.data.sync_status for x in config_entries
]
leader_sync_status: SyncStatus | None = None
if self.sync_status.leader is None:
leader_sync_status = self.sync_status
else:
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
for sync_status in sync_status_list:
if sync_status.id == required_id:
leader_sync_status = sync_status
break
if leader_sync_status is None or leader_sync_status.followers is None:
@ -594,9 +466,9 @@ class BluesoundPlayer(MediaPlayerEntity):
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
if x.sync_status.id in follower_ids
sync_status.name
for sync_status in sync_status_list
if sync_status.id in follower_ids
]
follower_names.insert(0, leader_sync_status.name)
return follower_names

View File

@ -9,7 +9,6 @@
'media_artist': 'artist',
'media_content_type': <MediaType.MUSIC: 'music'>,
'media_duration': 123,
'media_position': 2,
'media_title': 'song',
'shuffle': False,
'source_list': list([

View File

@ -127,7 +127,9 @@ async def test_attributes_set(
) -> None:
"""Test the media player attributes set."""
state = hass.states.get("media_player.player_name1111")
assert state == snapshot(exclude=props("media_position_updated_at"))
assert state == snapshot(
exclude=props("media_position_updated_at", "media_position")
)
async def test_stop_maps_to_idle(