Refactor squeezebox integration media_player to use coordinator (#127695)

pull/129414/head^2
Raj Laud 2024-10-29 09:21:28 -04:00 committed by GitHub
parent 9bda3bd477
commit 07c070e253
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 210 additions and 118 deletions

View File

@ -2,9 +2,10 @@
from asyncio import timeout
from dataclasses import dataclass
from datetime import datetime
import logging
from pysqueezebox import Server
from pysqueezebox import Player, Server
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
@ -23,20 +24,30 @@ from homeassistant.helpers.device_registry import (
DeviceEntryType,
format_mac,
)
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.event import async_call_later
from .const import (
CONF_HTTPS,
DISCOVERY_INTERVAL,
DISCOVERY_TASK,
DOMAIN,
KNOWN_PLAYERS,
KNOWN_SERVERS,
MANUFACTURER,
SERVER_MODEL,
SIGNAL_PLAYER_DISCOVERED,
SIGNAL_PLAYER_REDISCOVERED,
STATUS_API_TIMEOUT,
STATUS_QUERY_LIBRARYNAME,
STATUS_QUERY_MAC,
STATUS_QUERY_UUID,
STATUS_QUERY_VERSION,
)
from .coordinator import LMSStatusDataUpdateCoordinator
from .coordinator import (
LMSStatusDataUpdateCoordinator,
SqueezeBoxPlayerUpdateCoordinator,
)
_LOGGER = logging.getLogger(__name__)
@ -117,15 +128,55 @@ async def async_setup_entry(hass: HomeAssistant, entry: SqueezeboxConfigEntry) -
)
_LOGGER.debug("LMS Device %s", device)
coordinator = LMSStatusDataUpdateCoordinator(hass, lms)
server_coordinator = LMSStatusDataUpdateCoordinator(hass, lms)
entry.runtime_data = SqueezeboxData(
coordinator=coordinator,
coordinator=server_coordinator,
server=lms,
)
await coordinator.async_config_entry_first_refresh()
# set up player discovery
known_servers = hass.data.setdefault(DOMAIN, {}).setdefault(KNOWN_SERVERS, {})
known_players = known_servers.setdefault(lms.uuid, {}).setdefault(KNOWN_PLAYERS, [])
async def _player_discovery(now: datetime | None = None) -> None:
"""Discover squeezebox players by polling server."""
async def _discovered_player(player: Player) -> None:
"""Handle a (re)discovered player."""
if player.player_id in known_players:
await player.async_update()
async_dispatcher_send(
hass, SIGNAL_PLAYER_REDISCOVERED, player.player_id, player.connected
)
else:
_LOGGER.debug("Adding new entity: %s", player)
player_coordinator = SqueezeBoxPlayerUpdateCoordinator(
hass, player, lms.uuid
)
known_players.append(player.player_id)
async_dispatcher_send(
hass, SIGNAL_PLAYER_DISCOVERED, player_coordinator
)
if players := await lms.async_get_players():
for player in players:
hass.async_create_task(_discovered_player(player))
entry.async_on_unload(
async_call_later(hass, DISCOVERY_INTERVAL, _player_discovery)
)
await server_coordinator.async_config_entry_first_refresh()
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
_LOGGER.debug(
"Adding player discovery job for LMS server: %s", entry.data[CONF_HOST]
)
entry.async_create_background_task(
hass, _player_discovery(), "squeezebox.media_player.player_discovery"
)
return True

View File

@ -5,6 +5,7 @@ DISCOVERY_TASK = "discovery_task"
DOMAIN = "squeezebox"
DEFAULT_PORT = 9000
KNOWN_PLAYERS = "known_players"
KNOWN_SERVERS = "known_servers"
MANUFACTURER = "https://lyrion.org/"
PLAYER_DISCOVERY_UNSUB = "player_discovery_unsub"
SENSOR_UPDATE_INTERVAL = 60
@ -27,3 +28,7 @@ STATUS_QUERY_MAC = "mac"
STATUS_QUERY_UUID = "uuid"
STATUS_QUERY_VERSION = "version"
SQUEEZEBOX_SOURCE_STRINGS = ("source:", "wavin:", "spotify:")
SIGNAL_PLAYER_DISCOVERED = "squeezebox_player_discovered"
SIGNAL_PLAYER_REDISCOVERED = "squeezebox_player_rediscovered"
DISCOVERY_INTERVAL = 60
PLAYER_UPDATE_INTERVAL = 5

View File

@ -1,18 +1,23 @@
"""DataUpdateCoordinator for the Squeezebox integration."""
from asyncio import timeout
from collections.abc import Callable
from datetime import timedelta
import logging
import re
from typing import Any
from pysqueezebox import Server
from pysqueezebox import Player, Server
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util import dt as dt_util
from .const import (
PLAYER_UPDATE_INTERVAL,
SENSOR_UPDATE_INTERVAL,
SIGNAL_PLAYER_REDISCOVERED,
STATUS_API_TIMEOUT,
STATUS_SENSOR_LASTSCAN,
STATUS_SENSOR_NEEDSRESTART,
@ -38,7 +43,7 @@ class LMSStatusDataUpdateCoordinator(DataUpdateCoordinator):
self.newversion_regex = re.compile("<.*$")
async def _async_update_data(self) -> dict:
"""Fetch data fromn LMS status call.
"""Fetch data from LMS status call.
Then we process only a subset to make then nice for HA
"""
@ -70,3 +75,46 @@ class LMSStatusDataUpdateCoordinator(DataUpdateCoordinator):
_LOGGER.debug("Processed serverstatus %s=%s", self.lms.name, data)
return data
class SqueezeBoxPlayerUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"""Coordinator for Squeezebox players."""
def __init__(self, hass: HomeAssistant, player: Player, server_uuid: str) -> None:
"""Initialize the coordinator."""
super().__init__(
hass,
_LOGGER,
name=player.name,
update_interval=timedelta(seconds=PLAYER_UPDATE_INTERVAL),
always_update=True,
)
self.player = player
self.available = True
self._remove_dispatcher: Callable | None = None
self.server_uuid = server_uuid
async def _async_update_data(self) -> dict[str, Any]:
"""Update Player if available, or listen for rediscovery if not."""
if self.available:
# Only update players available at last update, unavailable players are rediscovered instead
await self.player.async_update()
if self.player.connected is False:
_LOGGER.debug("Player %s is not available", self.name)
self.available = False
# start listening for restored players
self._remove_dispatcher = async_dispatcher_connect(
self.hass, SIGNAL_PLAYER_REDISCOVERED, self.rediscovered
)
return {}
@callback
def rediscovered(self, unique_id: str, connected: bool) -> None:
"""Make a player available again."""
if unique_id == self.player.player_id and connected:
self.available = True
_LOGGER.debug("Player %s is available again", self.name)
if self._remove_dispatcher:
self._remove_dispatcher()

View File

@ -6,9 +6,9 @@ from collections.abc import Callable
from datetime import datetime
import json
import logging
from typing import Any
from typing import TYPE_CHECKING, Any
from pysqueezebox import Player, Server, async_discover
from pysqueezebox import Server, async_discover
import voluptuous as vol
from homeassistant.components import media_source
@ -25,50 +25,53 @@ from homeassistant.components.media_player import (
async_process_play_media_url,
)
from homeassistant.config_entries import SOURCE_INTEGRATION_DISCOVERY
from homeassistant.const import ATTR_COMMAND, CONF_HOST, CONF_PORT
from homeassistant.const import ATTR_COMMAND, CONF_HOST, CONF_PORT, Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import (
config_validation as cv,
discovery_flow,
entity_platform,
entity_registry as er,
)
from homeassistant.helpers.device_registry import (
CONNECTION_NETWORK_MAC,
DeviceInfo,
format_mac,
)
from homeassistant.helpers.dispatcher import (
async_dispatcher_connect,
async_dispatcher_send,
)
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.event import async_call_later
from homeassistant.helpers.start import async_at_start
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util.dt import utcnow
from . import SqueezeboxConfigEntry
from .browse_media import (
build_item_response,
generate_playlist,
library_payload,
media_source_content_filter,
)
from .const import DISCOVERY_TASK, DOMAIN, KNOWN_PLAYERS, SQUEEZEBOX_SOURCE_STRINGS
from .const import (
DISCOVERY_TASK,
DOMAIN,
KNOWN_PLAYERS,
KNOWN_SERVERS,
SIGNAL_PLAYER_DISCOVERED,
SQUEEZEBOX_SOURCE_STRINGS,
)
from .coordinator import SqueezeBoxPlayerUpdateCoordinator
if TYPE_CHECKING:
from . import SqueezeboxConfigEntry
SERVICE_CALL_METHOD = "call_method"
SERVICE_CALL_QUERY = "call_query"
ATTR_QUERY_RESULT = "query_result"
SIGNAL_PLAYER_REDISCOVERED = "squeezebox_player_rediscovered"
_LOGGER = logging.getLogger(__name__)
DISCOVERY_INTERVAL = 60
KNOWN_SERVERS = "known_servers"
ATTR_PARAMETERS = "parameters"
ATTR_OTHER_PLAYER = "other_player"
@ -112,49 +115,15 @@ async def async_setup_entry(
entry: SqueezeboxConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up an player discovery from a config entry."""
hass.data.setdefault(DOMAIN, {})
known_players = hass.data[DOMAIN].setdefault(KNOWN_PLAYERS, [])
lms = entry.runtime_data.server
"""Set up the Squeezebox media_player platform from a server config entry."""
async def _player_discovery(now: datetime | None = None) -> None:
"""Discover squeezebox players by polling server."""
# Add media player entities when discovered
async def _player_discovered(player: SqueezeBoxPlayerUpdateCoordinator) -> None:
_LOGGER.debug("Setting up media_player entity for player %s", player)
async_add_entities([SqueezeBoxMediaPlayerEntity(player)])
async def _discovered_player(player: Player) -> None:
"""Handle a (re)discovered player."""
entity = next(
(
known
for known in known_players
if known.unique_id == player.player_id
),
None,
)
if entity:
await player.async_update()
async_dispatcher_send(
hass, SIGNAL_PLAYER_REDISCOVERED, player.player_id, player.connected
)
if not entity:
_LOGGER.debug("Adding new entity: %s", player)
entity = SqueezeBoxEntity(player, lms)
known_players.append(entity)
async_add_entities([entity], True)
if players := await lms.async_get_players():
for player in players:
hass.async_create_task(_discovered_player(player))
entry.async_on_unload(
async_call_later(hass, DISCOVERY_INTERVAL, _player_discovery)
)
_LOGGER.debug(
"Adding player discovery job for LMS server: %s", entry.data[CONF_HOST]
)
entry.async_create_background_task(
hass, _player_discovery(), "squeezebox.media_player.player_discovery"
entry.async_on_unload(
async_dispatcher_connect(hass, SIGNAL_PLAYER_DISCOVERED, _player_discovered)
)
# Register entity services
@ -184,8 +153,10 @@ async def async_setup_entry(
entry.async_on_unload(async_at_start(hass, start_server_discovery))
class SqueezeBoxEntity(MediaPlayerEntity):
"""Representation of a SqueezeBox device.
class SqueezeBoxMediaPlayerEntity(
CoordinatorEntity[SqueezeBoxPlayerUpdateCoordinator], MediaPlayerEntity
):
"""Representation of the media player features of a SqueezeBox device.
Wraps a pysqueezebox.Player() object.
"""
@ -212,13 +183,18 @@ class SqueezeBoxEntity(MediaPlayerEntity):
_attr_has_entity_name = True
_attr_name = None
_last_update: datetime | None = None
_attr_available = True
def __init__(self, player: Player, server: Server) -> None:
def __init__(
self,
coordinator: SqueezeBoxPlayerUpdateCoordinator,
) -> None:
"""Initialize the SqueezeBox device."""
super().__init__(coordinator)
player = coordinator.player
self._player = player
self._query_result: bool | dict = {}
self._remove_dispatcher: Callable | None = None
self._previous_media_position = 0
self._attr_unique_id = format_mac(player.player_id)
_manufacturer = None
if player.model == "SqueezeLite" or "SqueezePlay" in player.model:
@ -234,11 +210,24 @@ class SqueezeBoxEntity(MediaPlayerEntity):
identifiers={(DOMAIN, self._attr_unique_id)},
name=player.name,
connections={(CONNECTION_NETWORK_MAC, self._attr_unique_id)},
via_device=(DOMAIN, server.uuid),
via_device=(DOMAIN, coordinator.server_uuid),
model=player.model,
manufacturer=_manufacturer,
)
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
if self._previous_media_position != self.media_position:
self._previous_media_position = self.media_position
self._last_update = utcnow()
self.async_write_ha_state()
@property
def available(self) -> bool:
"""Return True if entity is available."""
return self.coordinator.available and super().available
@property
def extra_state_attributes(self) -> dict[str, Any]:
"""Return device-specific attributes."""
@ -248,15 +237,6 @@ class SqueezeBoxEntity(MediaPlayerEntity):
if getattr(self, attr) is not None
}
@callback
def rediscovered(self, unique_id: str, connected: bool) -> None:
"""Make a player available again."""
if unique_id == self.unique_id and connected:
self._attr_available = True
_LOGGER.debug("Player %s is available again", self.name)
if self._remove_dispatcher:
self._remove_dispatcher()
@property
def state(self) -> MediaPlayerState | None:
"""Return the state of the device."""
@ -269,26 +249,11 @@ class SqueezeBoxEntity(MediaPlayerEntity):
)
return None
async def async_update(self) -> None:
"""Update the Player() object."""
# only update available players, newly available players will be rediscovered and marked available
if self._attr_available:
last_media_position = self.media_position
await self._player.async_update()
if self.media_position != last_media_position:
self._last_update = utcnow()
if self._player.connected is False:
_LOGGER.debug("Player %s is not available", self.name)
self._attr_available = False
# start listening for restored players
self._remove_dispatcher = async_dispatcher_connect(
self.hass, SIGNAL_PLAYER_REDISCOVERED, self.rediscovered
)
async def async_will_remove_from_hass(self) -> None:
"""Remove from list of known players when removed from hass."""
self.hass.data[DOMAIN][KNOWN_PLAYERS].remove(self)
known_servers = self.hass.data[DOMAIN][KNOWN_SERVERS]
known_players = known_servers[self.coordinator.server_uuid][KNOWN_PLAYERS]
known_players.remove(self.coordinator.player.player_id)
@property
def volume_level(self) -> float | None:
@ -380,13 +345,15 @@ class SqueezeBoxEntity(MediaPlayerEntity):
@property
def group_members(self) -> list[str]:
"""List players we are synced with."""
player_ids = {
p.unique_id: p.entity_id for p in self.hass.data[DOMAIN][KNOWN_PLAYERS]
}
ent_reg = er.async_get(self.hass)
return [
player_ids[player]
entity_id
for player in self._player.sync_group
if player in player_ids
if (
entity_id := ent_reg.async_get_entity_id(
Platform.MEDIA_PLAYER, DOMAIN, player
)
)
]
@property
@ -397,55 +364,68 @@ class SqueezeBoxEntity(MediaPlayerEntity):
async def async_turn_off(self) -> None:
"""Turn off media player."""
await self._player.async_set_power(False)
await self.coordinator.async_refresh()
async def async_volume_up(self) -> None:
"""Volume up media player."""
await self._player.async_set_volume("+5")
await self.coordinator.async_refresh()
async def async_volume_down(self) -> None:
"""Volume down media player."""
await self._player.async_set_volume("-5")
await self.coordinator.async_refresh()
async def async_set_volume_level(self, volume: float) -> None:
"""Set volume level, range 0..1."""
volume_percent = str(int(volume * 100))
await self._player.async_set_volume(volume_percent)
await self.coordinator.async_refresh()
async def async_mute_volume(self, mute: bool) -> None:
"""Mute (true) or unmute (false) media player."""
await self._player.async_set_muting(mute)
await self.coordinator.async_refresh()
async def async_media_stop(self) -> None:
"""Send stop command to media player."""
await self._player.async_stop()
await self.coordinator.async_refresh()
async def async_media_play_pause(self) -> None:
"""Send pause command to media player."""
await self._player.async_toggle_pause()
await self.coordinator.async_refresh()
async def async_media_play(self) -> None:
"""Send play command to media player."""
await self._player.async_play()
await self.coordinator.async_refresh()
async def async_media_pause(self) -> None:
"""Send pause command to media player."""
await self._player.async_pause()
await self.coordinator.async_refresh()
async def async_media_next_track(self) -> None:
"""Send next track command."""
await self._player.async_index("+1")
await self.coordinator.async_refresh()
async def async_media_previous_track(self) -> None:
"""Send next track command."""
await self._player.async_index("-1")
await self.coordinator.async_refresh()
async def async_media_seek(self, position: float) -> None:
"""Send seek command."""
await self._player.async_time(position)
await self.coordinator.async_refresh()
async def async_turn_on(self) -> None:
"""Turn the media player on."""
await self._player.async_set_power(True)
await self.coordinator.async_refresh()
async def async_play_media(
self, media_type: MediaType | str, media_id: str, **kwargs: Any
@ -504,6 +484,7 @@ class SqueezeBoxEntity(MediaPlayerEntity):
await self._player.async_load_playlist(playlist, cmd)
if index is not None:
await self._player.async_index(index)
await self.coordinator.async_refresh()
async def async_set_repeat(self, repeat: RepeatMode) -> None:
"""Set the repeat mode."""
@ -515,15 +496,18 @@ class SqueezeBoxEntity(MediaPlayerEntity):
repeat_mode = "none"
await self._player.async_set_repeat(repeat_mode)
await self.coordinator.async_refresh()
async def async_set_shuffle(self, shuffle: bool) -> None:
"""Enable/disable shuffle mode."""
shuffle_mode = "song" if shuffle else "none"
await self._player.async_set_shuffle(shuffle_mode)
await self.coordinator.async_refresh()
async def async_clear_playlist(self) -> None:
"""Send the media player the command for clear playlist."""
await self._player.async_clear_playlist()
await self.coordinator.async_refresh()
async def async_call_method(
self, command: str, parameters: list[str] | None = None
@ -558,21 +542,24 @@ class SqueezeBoxEntity(MediaPlayerEntity):
If the other player is a member of a sync group, it will leave the current sync group
without asking.
"""
player_ids = {
p.entity_id: p.unique_id for p in self.hass.data[DOMAIN][KNOWN_PLAYERS]
}
for other_player in group_members:
if other_player_id := player_ids.get(other_player):
ent_reg = er.async_get(self.hass)
for other_player_entity_id in group_members:
other_player = ent_reg.async_get(other_player_entity_id)
if other_player is None:
raise ServiceValidationError(
f"Could not find player with entity_id {other_player_entity_id}"
)
if other_player_id := other_player.unique_id:
await self._player.async_sync(other_player_id)
else:
raise ServiceValidationError(
f"Could not join unknown player {other_player}"
f"Could not join unknown player {other_player_entity_id}"
)
async def async_unjoin_player(self) -> None:
"""Unsync this Squeezebox player."""
await self._player.async_unsync()
await self.coordinator.async_refresh()
async def async_browse_media(
self,

View File

@ -207,7 +207,7 @@ def player_factory() -> MagicMock:
def mock_pysqueezebox_player(uuid: str) -> MagicMock:
"""Mock a Lyrion Media Server player."""
with patch(
"homeassistant.components.squeezebox.media_player.Player", autospec=True
"homeassistant.components.squeezebox.Player", autospec=True
) as mock_player:
mock_player.async_browse = AsyncMock(side_effect=mock_async_browse)
mock_player.generate_image_url_from_track_id = MagicMock(

View File

@ -30,10 +30,14 @@ from homeassistant.components.media_player import (
MediaType,
RepeatMode,
)
from homeassistant.components.squeezebox.const import DOMAIN, SENSOR_UPDATE_INTERVAL
from homeassistant.components.squeezebox.const import (
DISCOVERY_INTERVAL,
DOMAIN,
PLAYER_UPDATE_INTERVAL,
SENSOR_UPDATE_INTERVAL,
)
from homeassistant.components.squeezebox.media_player import (
ATTR_PARAMETERS,
DISCOVERY_INTERVAL,
SERVICE_CALL_METHOD,
SERVICE_CALL_QUERY,
)
@ -101,12 +105,9 @@ async def test_squeezebox_player_rediscovery(
# Make the player appear unavailable
configured_player.connected = False
await hass.services.async_call(
MEDIA_PLAYER_DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: "media_player.test_player"},
blocking=True,
)
freezer.tick(timedelta(seconds=PLAYER_UPDATE_INTERVAL))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert hass.states.get("media_player.test_player").state == STATE_UNAVAILABLE
# Make the player available again
@ -115,7 +116,7 @@ async def test_squeezebox_player_rediscovery(
async_fire_time_changed(hass)
await hass.async_block_till_done()
freezer.tick(timedelta(seconds=SENSOR_UPDATE_INTERVAL))
freezer.tick(timedelta(seconds=PLAYER_UPDATE_INTERVAL))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert hass.states.get("media_player.test_player").state == MediaPlayerState.IDLE