diff --git a/homeassistant/components/bluesound/__init__.py b/homeassistant/components/bluesound/__init__.py index b3facc0b8ac..6cf1957f799 100644 --- a/homeassistant/components/bluesound/__init__.py +++ b/homeassistant/components/bluesound/__init__.py @@ -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) diff --git a/homeassistant/components/bluesound/coordinator.py b/homeassistant/components/bluesound/coordinator.py new file mode 100644 index 00000000000..e62f3ef96cf --- /dev/null +++ b/homeassistant/components/bluesound/coordinator.py @@ -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()) diff --git a/homeassistant/components/bluesound/media_player.py b/homeassistant/components/bluesound/media_player.py index e850c059e52..12e2f537935 100644 --- a/homeassistant/components/bluesound/media_player.py +++ b/homeassistant/components/bluesound/media_player.py @@ -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 diff --git a/tests/components/bluesound/snapshots/test_media_player.ambr b/tests/components/bluesound/snapshots/test_media_player.ambr index 3e644d3038a..f71302f286d 100644 --- a/tests/components/bluesound/snapshots/test_media_player.ambr +++ b/tests/components/bluesound/snapshots/test_media_player.ambr @@ -9,7 +9,6 @@ 'media_artist': 'artist', 'media_content_type': , 'media_duration': 123, - 'media_position': 2, 'media_title': 'song', 'shuffle': False, 'source_list': list([ diff --git a/tests/components/bluesound/test_media_player.py b/tests/components/bluesound/test_media_player.py index a43696a0a7f..ed537d0bc57 100644 --- a/tests/components/bluesound/test_media_player.py +++ b/tests/components/bluesound/test_media_player.py @@ -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(