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:
2026-02-04 14:02:53 +03:00
parent 67a89e8349
commit 5519e449cd
8 changed files with 521 additions and 8 deletions

View File

@@ -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."""