Add WebSocket support for real-time media status updates
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>
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
@@ -14,7 +15,7 @@ from homeassistant.components.media_player import (
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_NAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
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 (
|
||||
@@ -23,14 +24,18 @@ from homeassistant.helpers.update_coordinator import (
|
||||
UpdateFailed,
|
||||
)
|
||||
|
||||
from .api_client import MediaServerClient, MediaServerError
|
||||
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__)
|
||||
@@ -62,13 +67,26 @@ async def async_setup_entry(
|
||||
entry.data.get(CONF_POLL_INTERVAL, DEFAULT_POLL_INTERVAL),
|
||||
)
|
||||
|
||||
# Create update coordinator
|
||||
# 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()
|
||||
@@ -76,6 +94,9 @@ async def async_setup_entry(
|
||||
_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,
|
||||
@@ -86,13 +107,17 @@ async def async_setup_entry(
|
||||
|
||||
|
||||
class MediaPlayerCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
"""Coordinator for fetching media player data."""
|
||||
"""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.
|
||||
|
||||
@@ -100,6 +125,10 @@ class MediaPlayerCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
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,
|
||||
@@ -108,9 +137,76 @@ class MediaPlayerCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
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.
|
||||
"""Fetch data from the API (fallback when WebSocket unavailable).
|
||||
|
||||
Returns:
|
||||
Media status data
|
||||
@@ -120,7 +216,7 @@ class MediaPlayerCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
"""
|
||||
try:
|
||||
data = await self.client.get_status()
|
||||
_LOGGER.debug("Received media status: %s", data)
|
||||
_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
|
||||
@@ -128,6 +224,17 @@ class MediaPlayerCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
_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."""
|
||||
|
||||
Reference in New Issue
Block a user