Replace HTTP polling with WebSocket push notifications for instant state change responses. Server broadcasts updates only when significant changes occur (state, track, volume, etc.) while letting Home Assistant interpolate position during playback. Includes seek detection for timeline updates and automatic fallback to HTTP polling if WebSocket disconnects. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
453 lines
15 KiB
Python
453 lines
15 KiB
Python
"""Media player platform for Remote Media Player integration."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
from datetime import datetime, timedelta
|
|
from typing import Any
|
|
|
|
from homeassistant.components.media_player import (
|
|
MediaPlayerEntity,
|
|
MediaPlayerEntityFeature,
|
|
MediaPlayerState,
|
|
MediaType,
|
|
)
|
|
from homeassistant.config_entries import ConfigEntry
|
|
from homeassistant.const import CONF_NAME
|
|
from homeassistant.core import HomeAssistant, callback
|
|
from homeassistant.helpers.entity import DeviceInfo
|
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
|
from homeassistant.helpers.update_coordinator import (
|
|
CoordinatorEntity,
|
|
DataUpdateCoordinator,
|
|
UpdateFailed,
|
|
)
|
|
|
|
from .api_client import MediaServerClient, MediaServerError, MediaServerWebSocket
|
|
from .const import (
|
|
DOMAIN,
|
|
CONF_HOST,
|
|
CONF_PORT,
|
|
CONF_TOKEN,
|
|
CONF_POLL_INTERVAL,
|
|
CONF_USE_WEBSOCKET,
|
|
DEFAULT_POLL_INTERVAL,
|
|
DEFAULT_NAME,
|
|
DEFAULT_USE_WEBSOCKET,
|
|
DEFAULT_RECONNECT_INTERVAL,
|
|
)
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
|
|
async def async_setup_entry(
|
|
hass: HomeAssistant,
|
|
entry: ConfigEntry,
|
|
async_add_entities: AddEntitiesCallback,
|
|
) -> None:
|
|
"""Set up the media player platform.
|
|
|
|
Args:
|
|
hass: Home Assistant instance
|
|
entry: Config entry
|
|
async_add_entities: Callback to add entities
|
|
"""
|
|
_LOGGER.debug("Setting up media player platform for %s", entry.entry_id)
|
|
|
|
try:
|
|
client: MediaServerClient = hass.data[DOMAIN][entry.entry_id]["client"]
|
|
except KeyError:
|
|
_LOGGER.error("Client not found in hass.data for entry %s", entry.entry_id)
|
|
return
|
|
|
|
# Get poll interval from options or data
|
|
poll_interval = entry.options.get(
|
|
CONF_POLL_INTERVAL,
|
|
entry.data.get(CONF_POLL_INTERVAL, DEFAULT_POLL_INTERVAL),
|
|
)
|
|
|
|
# Get WebSocket setting from options or data
|
|
use_websocket = entry.options.get(
|
|
CONF_USE_WEBSOCKET,
|
|
entry.data.get(CONF_USE_WEBSOCKET, DEFAULT_USE_WEBSOCKET),
|
|
)
|
|
|
|
# Create update coordinator with WebSocket support
|
|
coordinator = MediaPlayerCoordinator(
|
|
hass,
|
|
client,
|
|
poll_interval,
|
|
host=entry.data[CONF_HOST],
|
|
port=entry.data[CONF_PORT],
|
|
token=entry.data[CONF_TOKEN],
|
|
use_websocket=use_websocket,
|
|
)
|
|
|
|
# Set up WebSocket connection if enabled
|
|
await coordinator.async_setup()
|
|
|
|
# Fetch initial data - don't fail setup if this fails
|
|
try:
|
|
await coordinator.async_config_entry_first_refresh()
|
|
except Exception as err:
|
|
_LOGGER.warning("Initial data fetch failed, will retry: %s", err)
|
|
# Continue anyway - the coordinator will retry
|
|
|
|
# Store coordinator for cleanup
|
|
hass.data[DOMAIN][entry.entry_id]["coordinator"] = coordinator
|
|
|
|
# Create and add entity
|
|
entity = RemoteMediaPlayerEntity(
|
|
coordinator,
|
|
entry,
|
|
)
|
|
_LOGGER.info("Adding media player entity: %s", entity.unique_id)
|
|
async_add_entities([entity])
|
|
|
|
|
|
class MediaPlayerCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|
"""Coordinator for fetching media player data with WebSocket support."""
|
|
|
|
def __init__(
|
|
self,
|
|
hass: HomeAssistant,
|
|
client: MediaServerClient,
|
|
poll_interval: int,
|
|
host: str,
|
|
port: int,
|
|
token: str,
|
|
use_websocket: bool = True,
|
|
) -> None:
|
|
"""Initialize the coordinator.
|
|
|
|
Args:
|
|
hass: Home Assistant instance
|
|
client: Media Server API client
|
|
poll_interval: Update interval in seconds
|
|
host: Server hostname
|
|
port: Server port
|
|
token: API token
|
|
use_websocket: Whether to use WebSocket for updates
|
|
"""
|
|
super().__init__(
|
|
hass,
|
|
_LOGGER,
|
|
name="Remote Media Player",
|
|
update_interval=timedelta(seconds=poll_interval),
|
|
)
|
|
self.client = client
|
|
self._host = host
|
|
self._port = port
|
|
self._token = token
|
|
self._use_websocket = use_websocket
|
|
self._ws_client: MediaServerWebSocket | None = None
|
|
self._ws_connected = False
|
|
self._reconnect_task: asyncio.Task | None = None
|
|
self._poll_interval = poll_interval
|
|
|
|
async def async_setup(self) -> None:
|
|
"""Set up the coordinator with WebSocket if enabled."""
|
|
if self._use_websocket:
|
|
await self._connect_websocket()
|
|
|
|
async def _connect_websocket(self) -> None:
|
|
"""Establish WebSocket connection."""
|
|
if self._ws_client:
|
|
await self._ws_client.disconnect()
|
|
|
|
self._ws_client = MediaServerWebSocket(
|
|
host=self._host,
|
|
port=self._port,
|
|
token=self._token,
|
|
on_status_update=self._handle_ws_status_update,
|
|
on_disconnect=self._handle_ws_disconnect,
|
|
)
|
|
|
|
if await self._ws_client.connect():
|
|
self._ws_connected = True
|
|
# Disable polling - WebSocket handles all updates including position
|
|
self.update_interval = None
|
|
_LOGGER.info("WebSocket connected, polling disabled")
|
|
else:
|
|
self._ws_connected = False
|
|
# Keep polling as fallback
|
|
self.update_interval = timedelta(seconds=self._poll_interval)
|
|
_LOGGER.warning("WebSocket failed, falling back to polling")
|
|
# Schedule reconnect attempt
|
|
self._schedule_reconnect()
|
|
|
|
@callback
|
|
def _handle_ws_status_update(self, status_data: dict[str, Any]) -> None:
|
|
"""Handle status update from WebSocket."""
|
|
self.async_set_updated_data(status_data)
|
|
|
|
@callback
|
|
def _handle_ws_disconnect(self) -> None:
|
|
"""Handle WebSocket disconnection."""
|
|
self._ws_connected = False
|
|
# Re-enable polling as fallback
|
|
self.update_interval = timedelta(seconds=self._poll_interval)
|
|
_LOGGER.warning("WebSocket disconnected, falling back to polling")
|
|
# Schedule reconnect attempt
|
|
self._schedule_reconnect()
|
|
|
|
def _schedule_reconnect(self) -> None:
|
|
"""Schedule a WebSocket reconnection attempt."""
|
|
if self._reconnect_task and not self._reconnect_task.done():
|
|
return # Already scheduled
|
|
|
|
async def reconnect() -> None:
|
|
await asyncio.sleep(DEFAULT_RECONNECT_INTERVAL)
|
|
if self._use_websocket and not self._ws_connected:
|
|
_LOGGER.info("Attempting WebSocket reconnect...")
|
|
await self._connect_websocket()
|
|
|
|
self._reconnect_task = self.hass.async_create_task(reconnect())
|
|
|
|
async def _async_update_data(self) -> dict[str, Any]:
|
|
"""Fetch data from the API (fallback when WebSocket unavailable).
|
|
|
|
Returns:
|
|
Media status data
|
|
|
|
Raises:
|
|
UpdateFailed: On API errors
|
|
"""
|
|
try:
|
|
data = await self.client.get_status()
|
|
_LOGGER.debug("HTTP poll received status: %s", data.get("state"))
|
|
return data
|
|
except MediaServerError as err:
|
|
raise UpdateFailed(f"Error communicating with server: {err}") from err
|
|
except Exception as err:
|
|
_LOGGER.exception("Unexpected error fetching media status")
|
|
raise UpdateFailed(f"Unexpected error: {err}") from err
|
|
|
|
async def async_shutdown(self) -> None:
|
|
"""Clean up resources."""
|
|
if self._reconnect_task:
|
|
self._reconnect_task.cancel()
|
|
try:
|
|
await self._reconnect_task
|
|
except asyncio.CancelledError:
|
|
pass
|
|
if self._ws_client:
|
|
await self._ws_client.disconnect()
|
|
|
|
|
|
class RemoteMediaPlayerEntity(CoordinatorEntity[MediaPlayerCoordinator], MediaPlayerEntity):
|
|
"""Representation of a Remote Media Player."""
|
|
|
|
_attr_has_entity_name = True
|
|
_attr_name = None
|
|
|
|
@property
|
|
def available(self) -> bool:
|
|
"""Return True if entity is available."""
|
|
# Use the coordinator's last_update_success to detect server availability
|
|
return self.coordinator.last_update_success
|
|
|
|
def __init__(
|
|
self,
|
|
coordinator: MediaPlayerCoordinator,
|
|
entry: ConfigEntry,
|
|
) -> None:
|
|
"""Initialize the media player entity.
|
|
|
|
Args:
|
|
coordinator: Data update coordinator
|
|
entry: Config entry
|
|
"""
|
|
super().__init__(coordinator)
|
|
self._entry = entry
|
|
self._attr_unique_id = f"{entry.entry_id}_media_player"
|
|
|
|
# Device info - must match button.py identifiers
|
|
self._attr_device_info = DeviceInfo(
|
|
identifiers={(DOMAIN, entry.entry_id)},
|
|
name=entry.title,
|
|
manufacturer="Remote Media Player",
|
|
model="Media Server",
|
|
sw_version="1.0.0",
|
|
configuration_url=f"http://{entry.data[CONF_HOST]}:{int(entry.data[CONF_PORT])}/docs",
|
|
)
|
|
|
|
@property
|
|
def supported_features(self) -> MediaPlayerEntityFeature:
|
|
"""Return the supported features."""
|
|
return (
|
|
MediaPlayerEntityFeature.PAUSE
|
|
| MediaPlayerEntityFeature.PLAY
|
|
| MediaPlayerEntityFeature.STOP
|
|
| MediaPlayerEntityFeature.VOLUME_SET
|
|
| MediaPlayerEntityFeature.VOLUME_MUTE
|
|
| MediaPlayerEntityFeature.PREVIOUS_TRACK
|
|
| MediaPlayerEntityFeature.NEXT_TRACK
|
|
| MediaPlayerEntityFeature.SEEK
|
|
)
|
|
|
|
@property
|
|
def state(self) -> MediaPlayerState | None:
|
|
"""Return the state of the player."""
|
|
if self.coordinator.data is None:
|
|
return MediaPlayerState.OFF
|
|
|
|
state = self.coordinator.data.get("state", "idle")
|
|
state_map = {
|
|
"playing": MediaPlayerState.PLAYING,
|
|
"paused": MediaPlayerState.PAUSED,
|
|
"stopped": MediaPlayerState.IDLE,
|
|
"idle": MediaPlayerState.IDLE,
|
|
}
|
|
return state_map.get(state, MediaPlayerState.IDLE)
|
|
|
|
@property
|
|
def volume_level(self) -> float | None:
|
|
"""Return the volume level (0..1)."""
|
|
if self.coordinator.data is None:
|
|
return None
|
|
volume = self.coordinator.data.get("volume", 0)
|
|
return volume / 100.0
|
|
|
|
@property
|
|
def is_volume_muted(self) -> bool | None:
|
|
"""Return True if volume is muted."""
|
|
if self.coordinator.data is None:
|
|
return None
|
|
return self.coordinator.data.get("muted", False)
|
|
|
|
@property
|
|
def media_content_type(self) -> MediaType | None:
|
|
"""Return the content type of current playing media."""
|
|
return MediaType.MUSIC
|
|
|
|
@property
|
|
def media_title(self) -> str | None:
|
|
"""Return the title of current playing media."""
|
|
if self.coordinator.data is None:
|
|
return None
|
|
return self.coordinator.data.get("title")
|
|
|
|
@property
|
|
def media_artist(self) -> str | None:
|
|
"""Return the artist of current playing media."""
|
|
if self.coordinator.data is None:
|
|
return None
|
|
return self.coordinator.data.get("artist")
|
|
|
|
@property
|
|
def media_album_name(self) -> str | None:
|
|
"""Return the album name of current playing media."""
|
|
if self.coordinator.data is None:
|
|
return None
|
|
return self.coordinator.data.get("album")
|
|
|
|
@property
|
|
def media_image_url(self) -> str | None:
|
|
"""Return the image URL of current playing media."""
|
|
if self.coordinator.data is None:
|
|
return None
|
|
return self.coordinator.data.get("album_art_url")
|
|
|
|
@property
|
|
def media_duration(self) -> int | None:
|
|
"""Return the duration of current playing media in seconds."""
|
|
if self.coordinator.data is None:
|
|
return None
|
|
duration = self.coordinator.data.get("duration")
|
|
return int(duration) if duration is not None else None
|
|
|
|
@property
|
|
def media_position(self) -> int | None:
|
|
"""Return the position of current playing media in seconds."""
|
|
if self.coordinator.data is None:
|
|
return None
|
|
position = self.coordinator.data.get("position")
|
|
return int(position) if position is not None else None
|
|
|
|
@property
|
|
def media_position_updated_at(self) -> datetime | None:
|
|
"""Return when the position was last updated."""
|
|
if self.coordinator.data is None:
|
|
return None
|
|
if self.coordinator.data.get("position") is not None:
|
|
# Use last_update_success_time if available, otherwise use current time
|
|
if hasattr(self.coordinator, 'last_update_success_time'):
|
|
return self.coordinator.last_update_success_time
|
|
return datetime.now()
|
|
return None
|
|
|
|
@property
|
|
def source(self) -> str | None:
|
|
"""Return the current media source."""
|
|
if self.coordinator.data is None:
|
|
return None
|
|
return self.coordinator.data.get("source")
|
|
|
|
async def async_media_play(self) -> None:
|
|
"""Send play command."""
|
|
try:
|
|
await self.coordinator.client.play()
|
|
await self.coordinator.async_request_refresh()
|
|
except MediaServerError as err:
|
|
_LOGGER.error("Failed to play: %s", err)
|
|
|
|
async def async_media_pause(self) -> None:
|
|
"""Send pause command."""
|
|
try:
|
|
await self.coordinator.client.pause()
|
|
await self.coordinator.async_request_refresh()
|
|
except MediaServerError as err:
|
|
_LOGGER.error("Failed to pause: %s", err)
|
|
|
|
async def async_media_stop(self) -> None:
|
|
"""Send stop command."""
|
|
try:
|
|
await self.coordinator.client.stop()
|
|
await self.coordinator.async_request_refresh()
|
|
except MediaServerError as err:
|
|
_LOGGER.error("Failed to stop: %s", err)
|
|
|
|
async def async_media_next_track(self) -> None:
|
|
"""Send next track command."""
|
|
try:
|
|
await self.coordinator.client.next_track()
|
|
await self.coordinator.async_request_refresh()
|
|
except MediaServerError as err:
|
|
_LOGGER.error("Failed to skip to next track: %s", err)
|
|
|
|
async def async_media_previous_track(self) -> None:
|
|
"""Send previous track command."""
|
|
try:
|
|
await self.coordinator.client.previous_track()
|
|
await self.coordinator.async_request_refresh()
|
|
except MediaServerError as err:
|
|
_LOGGER.error("Failed to go to previous track: %s", err)
|
|
|
|
async def async_set_volume_level(self, volume: float) -> None:
|
|
"""Set volume level, range 0..1."""
|
|
try:
|
|
await self.coordinator.client.set_volume(int(volume * 100))
|
|
await self.coordinator.async_request_refresh()
|
|
except MediaServerError as err:
|
|
_LOGGER.error("Failed to set volume: %s", err)
|
|
|
|
async def async_mute_volume(self, mute: bool) -> None:
|
|
"""Mute/unmute the volume."""
|
|
try:
|
|
# Toggle mute (API toggles, so call it if state differs)
|
|
if self.is_volume_muted != mute:
|
|
await self.coordinator.client.toggle_mute()
|
|
await self.coordinator.async_request_refresh()
|
|
except MediaServerError as err:
|
|
_LOGGER.error("Failed to toggle mute: %s", err)
|
|
|
|
async def async_media_seek(self, position: float) -> None:
|
|
"""Seek to a position."""
|
|
try:
|
|
await self.coordinator.client.seek(position)
|
|
await self.coordinator.async_request_refresh()
|
|
except MediaServerError as err:
|
|
_LOGGER.error("Failed to seek: %s", err)
|