mirror of
https://github.com/home-assistant/core.git
synced 2025-07-20 11:47:06 +00:00
Add DataUpdateCoordinator to bluesound integration (#135125)
This commit is contained in:
parent
d06cd1ad3b
commit
acbd501ede
@ -14,10 +14,13 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
|||||||
from homeassistant.helpers.typing import ConfigType
|
from homeassistant.helpers.typing import ConfigType
|
||||||
|
|
||||||
from .const import DOMAIN
|
from .const import DOMAIN
|
||||||
|
from .coordinator import BluesoundCoordinator
|
||||||
|
|
||||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||||
|
|
||||||
PLATFORMS = [Platform.MEDIA_PLAYER]
|
PLATFORMS = [
|
||||||
|
Platform.MEDIA_PLAYER,
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@ -26,6 +29,7 @@ class BluesoundRuntimeData:
|
|||||||
|
|
||||||
player: Player
|
player: Player
|
||||||
sync_status: SyncStatus
|
sync_status: SyncStatus
|
||||||
|
coordinator: BluesoundCoordinator
|
||||||
|
|
||||||
|
|
||||||
type BluesoundConfigEntry = ConfigEntry[BluesoundRuntimeData]
|
type BluesoundConfigEntry = ConfigEntry[BluesoundRuntimeData]
|
||||||
@ -33,9 +37,6 @@ type BluesoundConfigEntry = ConfigEntry[BluesoundRuntimeData]
|
|||||||
|
|
||||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||||
"""Set up the Bluesound."""
|
"""Set up the Bluesound."""
|
||||||
if DOMAIN not in hass.data:
|
|
||||||
hass.data[DOMAIN] = []
|
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
@ -46,13 +47,16 @@ async def async_setup_entry(
|
|||||||
host = config_entry.data[CONF_HOST]
|
host = config_entry.data[CONF_HOST]
|
||||||
port = config_entry.data[CONF_PORT]
|
port = config_entry.data[CONF_PORT]
|
||||||
session = async_get_clientsession(hass)
|
session = async_get_clientsession(hass)
|
||||||
async with Player(host, port, session=session, default_timeout=10) as player:
|
player = Player(host, port, session=session, default_timeout=10)
|
||||||
try:
|
try:
|
||||||
sync_status = await player.sync_status(timeout=1)
|
sync_status = await player.sync_status(timeout=1)
|
||||||
except PlayerUnreachableError as ex:
|
except PlayerUnreachableError as ex:
|
||||||
raise ConfigEntryNotReady(f"Error connecting to {host}:{port}") from 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)
|
await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
|
||||||
|
|
||||||
|
160
homeassistant/components/bluesound/coordinator.py
Normal file
160
homeassistant/components/bluesound/coordinator.py
Normal 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())
|
@ -2,15 +2,12 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
from asyncio import Task
|
||||||
from asyncio import CancelledError, Task
|
|
||||||
from contextlib import suppress
|
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
import logging
|
import logging
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
from pyblu import Input, Player, Preset, Status, SyncStatus
|
from pyblu import Input, Player, Preset, Status, SyncStatus
|
||||||
from pyblu.errors import PlayerUnreachableError
|
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant.components import media_source
|
from homeassistant.components import media_source
|
||||||
@ -23,7 +20,7 @@ from homeassistant.components.media_player import (
|
|||||||
async_process_play_media_url,
|
async_process_play_media_url,
|
||||||
)
|
)
|
||||||
from homeassistant.const import CONF_HOST, CONF_PORT
|
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.exceptions import ServiceValidationError
|
||||||
from homeassistant.helpers import config_validation as cv, entity_platform
|
from homeassistant.helpers import config_validation as cv, entity_platform
|
||||||
from homeassistant.helpers.device_registry import (
|
from homeassistant.helpers.device_registry import (
|
||||||
@ -36,9 +33,11 @@ from homeassistant.helpers.dispatcher import (
|
|||||||
async_dispatcher_send,
|
async_dispatcher_send,
|
||||||
)
|
)
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
|
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||||
import homeassistant.util.dt as dt_util
|
import homeassistant.util.dt as dt_util
|
||||||
|
|
||||||
from .const import ATTR_BLUESOUND_GROUP, ATTR_MASTER, DOMAIN
|
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
|
from .utils import dispatcher_join_signal, dispatcher_unjoin_signal, format_unique_id
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@ -56,11 +55,6 @@ SERVICE_JOIN = "join"
|
|||||||
SERVICE_SET_TIMER = "set_sleep_timer"
|
SERVICE_SET_TIMER = "set_sleep_timer"
|
||||||
SERVICE_UNJOIN = "unjoin"
|
SERVICE_UNJOIN = "unjoin"
|
||||||
|
|
||||||
NODE_OFFLINE_CHECK_TIMEOUT = 180
|
|
||||||
NODE_RETRY_INITIATION = timedelta(minutes=3)
|
|
||||||
|
|
||||||
SYNC_STATUS_INTERVAL = timedelta(minutes=5)
|
|
||||||
|
|
||||||
POLL_TIMEOUT = 120
|
POLL_TIMEOUT = 120
|
||||||
|
|
||||||
|
|
||||||
@ -71,10 +65,10 @@ async def async_setup_entry(
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""Set up the Bluesound entry."""
|
"""Set up the Bluesound entry."""
|
||||||
bluesound_player = BluesoundPlayer(
|
bluesound_player = BluesoundPlayer(
|
||||||
|
config_entry.runtime_data.coordinator,
|
||||||
config_entry.data[CONF_HOST],
|
config_entry.data[CONF_HOST],
|
||||||
config_entry.data[CONF_PORT],
|
config_entry.data[CONF_PORT],
|
||||||
config_entry.runtime_data.player,
|
config_entry.runtime_data.player,
|
||||||
config_entry.runtime_data.sync_status,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
platform = entity_platform.async_get_current_platform()
|
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")
|
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)
|
async_add_entities([bluesound_player], update_before_add=True)
|
||||||
|
|
||||||
|
|
||||||
class BluesoundPlayer(MediaPlayerEntity):
|
class BluesoundPlayer(CoordinatorEntity[BluesoundCoordinator], MediaPlayerEntity):
|
||||||
"""Representation of a Bluesound Player."""
|
"""Representation of a Bluesound Player."""
|
||||||
|
|
||||||
_attr_media_content_type = MediaType.MUSIC
|
_attr_media_content_type = MediaType.MUSIC
|
||||||
@ -102,12 +95,15 @@ class BluesoundPlayer(MediaPlayerEntity):
|
|||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
|
coordinator: BluesoundCoordinator,
|
||||||
host: str,
|
host: str,
|
||||||
port: int,
|
port: int,
|
||||||
player: Player,
|
player: Player,
|
||||||
sync_status: SyncStatus,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Initialize the media player."""
|
"""Initialize the media player."""
|
||||||
|
super().__init__(coordinator)
|
||||||
|
sync_status = coordinator.data.sync_status
|
||||||
|
|
||||||
self.host = host
|
self.host = host
|
||||||
self.port = port
|
self.port = port
|
||||||
self._poll_status_loop_task: Task[None] | None = None
|
self._poll_status_loop_task: Task[None] | None = None
|
||||||
@ -115,15 +111,14 @@ class BluesoundPlayer(MediaPlayerEntity):
|
|||||||
self._id = sync_status.id
|
self._id = sync_status.id
|
||||||
self._last_status_update: datetime | None = None
|
self._last_status_update: datetime | None = None
|
||||||
self._sync_status = sync_status
|
self._sync_status = sync_status
|
||||||
self._status: Status | None = None
|
self._status: Status = coordinator.data.status
|
||||||
self._inputs: list[Input] = []
|
self._inputs: list[Input] = coordinator.data.inputs
|
||||||
self._presets: list[Preset] = []
|
self._presets: list[Preset] = coordinator.data.presets
|
||||||
self._group_name: str | None = None
|
self._group_name: str | None = None
|
||||||
self._group_list: list[str] = []
|
self._group_list: list[str] = []
|
||||||
self._bluesound_device_name = sync_status.name
|
self._bluesound_device_name = sync_status.name
|
||||||
self._player = player
|
self._player = player
|
||||||
self._is_leader = False
|
self._last_status_update = dt_util.utcnow()
|
||||||
self._leader: BluesoundPlayer | None = None
|
|
||||||
|
|
||||||
self._attr_unique_id = format_unique_id(sync_status.mac, port)
|
self._attr_unique_id = format_unique_id(sync_status.mac, port)
|
||||||
# there should always be one player with the default port per mac
|
# 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)),
|
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:
|
async def async_added_to_hass(self) -> None:
|
||||||
"""Start the polling task."""
|
"""Start the polling task."""
|
||||||
await super().async_added_to_hass()
|
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
|
assert self._sync_status.id is not None
|
||||||
self.async_on_remove(
|
self.async_on_remove(
|
||||||
async_dispatcher_connect(
|
async_dispatcher_connect(
|
||||||
@ -212,105 +165,24 @@ class BluesoundPlayer(MediaPlayerEntity):
|
|||||||
"""Stop the polling task."""
|
"""Stop the polling task."""
|
||||||
await super().async_will_remove_from_hass()
|
await super().async_will_remove_from_hass()
|
||||||
|
|
||||||
assert self._poll_status_loop_task is not None
|
@callback
|
||||||
if self._poll_status_loop_task.cancel():
|
def _handle_coordinator_update(self) -> None:
|
||||||
# the sleeps in _poll_loop will raise CancelledError
|
"""Handle updated data from the coordinator."""
|
||||||
with suppress(CancelledError):
|
self._sync_status = self.coordinator.data.sync_status
|
||||||
await self._poll_status_loop_task
|
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._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._group_list = self.rebuild_bluesound_group()
|
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()
|
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
|
@property
|
||||||
def state(self) -> MediaPlayerState:
|
def state(self) -> MediaPlayerState:
|
||||||
"""Return the state of the device."""
|
"""Return the state of the device."""
|
||||||
if self._status is None:
|
if self.available is False:
|
||||||
return MediaPlayerState.OFF
|
return MediaPlayerState.OFF
|
||||||
|
|
||||||
if self.is_grouped and not self.is_leader:
|
if self.is_grouped and not self.is_leader:
|
||||||
@ -327,7 +199,7 @@ class BluesoundPlayer(MediaPlayerEntity):
|
|||||||
@property
|
@property
|
||||||
def media_title(self) -> str | None:
|
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_leader):
|
if self.available is False or (self.is_grouped and not self.is_leader):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
return self._status.name
|
return self._status.name
|
||||||
@ -335,7 +207,7 @@ class BluesoundPlayer(MediaPlayerEntity):
|
|||||||
@property
|
@property
|
||||||
def media_artist(self) -> str | None:
|
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.available is False:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if self.is_grouped and not self.is_leader:
|
if self.is_grouped and not self.is_leader:
|
||||||
@ -346,7 +218,7 @@ class BluesoundPlayer(MediaPlayerEntity):
|
|||||||
@property
|
@property
|
||||||
def media_album_name(self) -> str | None:
|
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_leader):
|
if self.available is False or (self.is_grouped and not self.is_leader):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
return self._status.album
|
return self._status.album
|
||||||
@ -354,7 +226,7 @@ class BluesoundPlayer(MediaPlayerEntity):
|
|||||||
@property
|
@property
|
||||||
def media_image_url(self) -> str | None:
|
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_leader):
|
if self.available is False or (self.is_grouped and not self.is_leader):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
url = self._status.image
|
url = self._status.image
|
||||||
@ -369,7 +241,7 @@ class BluesoundPlayer(MediaPlayerEntity):
|
|||||||
@property
|
@property
|
||||||
def media_position(self) -> int | None:
|
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_leader):
|
if self.available is False or (self.is_grouped and not self.is_leader):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
mediastate = self.state
|
mediastate = self.state
|
||||||
@ -388,7 +260,7 @@ class BluesoundPlayer(MediaPlayerEntity):
|
|||||||
@property
|
@property
|
||||||
def media_duration(self) -> int | None:
|
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_leader):
|
if self.available is False or (self.is_grouped and not self.is_leader):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
duration = self._status.total_seconds
|
duration = self._status.total_seconds
|
||||||
@ -405,16 +277,11 @@ class BluesoundPlayer(MediaPlayerEntity):
|
|||||||
@property
|
@property
|
||||||
def volume_level(self) -> float | None:
|
def volume_level(self) -> float | None:
|
||||||
"""Volume level of the media player (0..1)."""
|
"""Volume level of the media player (0..1)."""
|
||||||
volume = None
|
|
||||||
|
|
||||||
if self._status is not None:
|
|
||||||
volume = self._status.volume
|
volume = self._status.volume
|
||||||
|
|
||||||
if self.is_grouped:
|
if self.is_grouped:
|
||||||
volume = self._sync_status.volume
|
volume = self._sync_status.volume
|
||||||
|
|
||||||
if volume is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
return volume / 100
|
return volume / 100
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@ -447,7 +314,7 @@ class BluesoundPlayer(MediaPlayerEntity):
|
|||||||
@property
|
@property
|
||||||
def source_list(self) -> list[str] | None:
|
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_leader):
|
if self.available is False or (self.is_grouped and not self.is_leader):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
sources = [x.text for x in self._inputs]
|
sources = [x.text for x in self._inputs]
|
||||||
@ -458,7 +325,7 @@ class BluesoundPlayer(MediaPlayerEntity):
|
|||||||
@property
|
@property
|
||||||
def source(self) -> str | None:
|
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_leader):
|
if self.available is False or (self.is_grouped and not self.is_leader):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if self._status.input_id is not None:
|
if self._status.input_id is not None:
|
||||||
@ -475,7 +342,7 @@ class BluesoundPlayer(MediaPlayerEntity):
|
|||||||
@property
|
@property
|
||||||
def supported_features(self) -> MediaPlayerEntityFeature:
|
def supported_features(self) -> MediaPlayerEntityFeature:
|
||||||
"""Flag of media commands that are supported."""
|
"""Flag of media commands that are supported."""
|
||||||
if self._status is None:
|
if self.available is False:
|
||||||
return MediaPlayerEntityFeature(0)
|
return MediaPlayerEntityFeature(0)
|
||||||
|
|
||||||
if self.is_grouped and not self.is_leader:
|
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:
|
if self.sync_status.leader is None and self.sync_status.followers is None:
|
||||||
return []
|
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
|
leader_sync_status: SyncStatus | None = None
|
||||||
if self.sync_status.leader is None:
|
if self.sync_status.leader is None:
|
||||||
leader_sync_status = self.sync_status
|
leader_sync_status = self.sync_status
|
||||||
else:
|
else:
|
||||||
required_id = f"{self.sync_status.leader.ip}:{self.sync_status.leader.port}"
|
required_id = f"{self.sync_status.leader.ip}:{self.sync_status.leader.port}"
|
||||||
for x in player_entities:
|
for sync_status in sync_status_list:
|
||||||
if x.sync_status.id == required_id:
|
if sync_status.id == required_id:
|
||||||
leader_sync_status = x.sync_status
|
leader_sync_status = sync_status
|
||||||
break
|
break
|
||||||
|
|
||||||
if leader_sync_status is None or leader_sync_status.followers is None:
|
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_ids = [f"{x.ip}:{x.port}" for x in leader_sync_status.followers]
|
||||||
follower_names = [
|
follower_names = [
|
||||||
x.sync_status.name
|
sync_status.name
|
||||||
for x in player_entities
|
for sync_status in sync_status_list
|
||||||
if x.sync_status.id in follower_ids
|
if sync_status.id in follower_ids
|
||||||
]
|
]
|
||||||
follower_names.insert(0, leader_sync_status.name)
|
follower_names.insert(0, leader_sync_status.name)
|
||||||
return follower_names
|
return follower_names
|
||||||
|
@ -9,7 +9,6 @@
|
|||||||
'media_artist': 'artist',
|
'media_artist': 'artist',
|
||||||
'media_content_type': <MediaType.MUSIC: 'music'>,
|
'media_content_type': <MediaType.MUSIC: 'music'>,
|
||||||
'media_duration': 123,
|
'media_duration': 123,
|
||||||
'media_position': 2,
|
|
||||||
'media_title': 'song',
|
'media_title': 'song',
|
||||||
'shuffle': False,
|
'shuffle': False,
|
||||||
'source_list': list([
|
'source_list': list([
|
||||||
|
@ -127,7 +127,9 @@ async def test_attributes_set(
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""Test the media player attributes set."""
|
"""Test the media player attributes set."""
|
||||||
state = hass.states.get("media_player.player_name1111")
|
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(
|
async def test_stop_maps_to_idle(
|
||||||
|
Loading…
x
Reference in New Issue
Block a user