Use RelayTV UI events for faster state updates

This commit is contained in:
2026-04-03 22:17:52 -05:00
parent 7dcc0becdc
commit 476b4e1be6
5 changed files with 206 additions and 10 deletions
+5 -2
View File
@@ -7,7 +7,10 @@ RelayTV integrates with Home Assistant as a local `media_player` plus RelayTV-sp
## Current Feature Set ## Current Feature Set
- Creates a `media_player` entity for each RelayTV config entry. - Creates a `media_player` entity for each RelayTV config entry.
- Polls RelayTV `GET /status` every 3 seconds. - Uses a hybrid RelayTV state model:
- `GET /status` for bootstrap, reconnect, and full refresh fallback
- `GET /ui/events` SSE for immediate UI-state updates
- authoritative `status` events plus fast-path `playback` / refresh-hint `queue` and `jellyfin` events
- Supports media controls from Home Assistant: - Supports media controls from Home Assistant:
- Play, pause, stop - Play, pause, stop
- Next and previous - Next and previous
@@ -101,7 +104,7 @@ data:
- No dedicated `enqueue` or `clear_queue` Home Assistant service is currently registered by this integration. - No dedicated `enqueue` or `clear_queue` Home Assistant service is currently registered by this integration.
- Overlay calls must include at least `text` or `image_url`. - Overlay calls must include at least `text` or `image_url`.
- Snapshots require active playback on the RelayTV server. - Snapshots require active playback on the RelayTV server.
- Integration uses local polling; it does not currently use WebSocket push updates. - `/ui/events` is treated as a live push stream, not a replay log; `/status` remains the reconnect/bootstrap fallback.
## Compatibility ## Compatibility
+6 -1
View File
@@ -5,7 +5,11 @@ This integration provides a RelayTV `media_player` entity, RelayTV service actio
## Implemented Behavior ## Implemented Behavior
- `media_player` platform is enabled (`custom_components/relaytv/media_player.py`). - `media_player` platform is enabled (`custom_components/relaytv/media_player.py`).
- Polling coordinator refreshes RelayTV `GET /status` every 3 seconds. - Hybrid state updates:
- bootstrap/reconnect uses RelayTV `GET /status`
- RelayTV `GET /ui/events` SSE provides hot-state updates
- `status` events are treated as authoritative full snapshots
- `playback` / `queue` / `jellyfin` events trigger fast updates or targeted refreshes
- Sidebar panel is registered via Home Assistant frontend iframe panel APIs. - Sidebar panel is registered via Home Assistant frontend iframe panel APIs.
- RelayTV services are registered from `services.yaml`: - RelayTV services are registered from `services.yaml`:
- `smart_url` - `smart_url`
@@ -41,5 +45,6 @@ This integration provides a RelayTV `media_player` entity, RelayTV service actio
- Overlay requires `text` or `image_url`. - Overlay requires `text` or `image_url`.
- Snapshot requires active playback on the RelayTV server. - Snapshot requires active playback on the RelayTV server.
- Snapshot responses are normalized to absolute URLs for Home Assistant entity attributes. - Snapshot responses are normalized to absolute URLs for Home Assistant entity attributes.
- The integration keeps `/status` as bootstrap/fallback and does not treat `/ui/events` as a replay log.
For fuller documentation and examples, see the repository root README. For fuller documentation and examples, see the repository root README.
+3
View File
@@ -321,6 +321,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
coordinator.async_add_listener(_save_resume_position) coordinator.async_add_listener(_save_resume_position)
await coordinator.async_config_entry_first_refresh() await coordinator.async_config_entry_first_refresh()
await coordinator.async_start()
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
hass.data[DOMAIN][entry.entry_id]["mapping_unsubs"] = _setup_mapping_listeners(hass, entry) hass.data[DOMAIN][entry.entry_id]["mapping_unsubs"] = _setup_mapping_listeners(hass, entry)
@@ -449,6 +450,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
entry_data = hass.data.get(DOMAIN, {}).pop(entry.entry_id, None) entry_data = hass.data.get(DOMAIN, {}).pop(entry.entry_id, None)
if entry_data: if entry_data:
await entry_data[DATA_COORDINATOR].async_stop()
for unsub in entry_data.get("mapping_unsubs", []): for unsub in entry_data.get("mapping_unsubs", []):
unsub() unsub()
@@ -483,6 +485,7 @@ async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> Non
store = _get_entry_data(hass, entry.entry_id) store = _get_entry_data(hass, entry.entry_id)
if store and base_url: if store and base_url:
store[DATA_API].base_url = base_url store[DATA_API].base_url = base_url
await store[DATA_COORDINATOR].async_restart()
for unsub in store.get("mapping_unsubs", []): for unsub in store.get("mapping_unsubs", []):
unsub() unsub()
store["mapping_unsubs"] = _setup_mapping_listeners(hass, entry) store["mapping_unsubs"] = _setup_mapping_listeners(hass, entry)
+186 -6
View File
@@ -1,9 +1,14 @@
"""Coordinator for RelayTV polling.""" """Coordinator for RelayTV status refresh and UI event streaming."""
from __future__ import annotations from __future__ import annotations
from datetime import timedelta import asyncio
import json
import logging import logging
from datetime import timedelta
from typing import Any
import aiohttp
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
@@ -12,21 +17,196 @@ from .relaytv_api import RelayTVApi
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
_POLL_INTERVAL_FALLBACK = timedelta(seconds=3)
_POLL_INTERVAL_SSE = timedelta(seconds=30)
_SSE_CONNECT_TIMEOUT = 10
_SSE_READ_TIMEOUT = 90
_SSE_REFRESH_DEBOUNCE_SEC = 0.25
class RelayTVCoordinator(DataUpdateCoordinator[dict]):
"""Poll RelayTV for its current status.""" def _merge_playback_snapshot(current: dict[str, Any] | None, payload: dict[str, Any]) -> dict[str, Any] | None:
"""Overlay compact playback-state fields onto the last full status payload."""
if not isinstance(current, dict):
return None
merged = dict(current)
merged.update(payload)
return merged
class RelayTVCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"""Hybrid RelayTV coordinator using /status plus /ui/events."""
def __init__(self, hass: HomeAssistant, api: RelayTVApi) -> None: def __init__(self, hass: HomeAssistant, api: RelayTVApi) -> None:
super().__init__( super().__init__(
hass, hass,
_LOGGER, _LOGGER,
name="RelayTV status", name="RelayTV status",
update_interval=timedelta(seconds=3), update_interval=_POLL_INTERVAL_FALLBACK,
) )
self.api = api self.api = api
self._sse_task: asyncio.Task[None] | None = None
self._refresh_task: asyncio.Task[None] | None = None
self._sse_enabled = False
async def _async_update_data(self) -> dict: async def _async_update_data(self) -> dict[str, Any]:
data = await self.api.get_status() data = await self.api.get_status()
if data is None: if data is None:
raise UpdateFailed("Unable to fetch RelayTV status") raise UpdateFailed("Unable to fetch RelayTV status")
return data return data
async def async_start(self) -> None:
"""Start the background SSE listener."""
if self._sse_task and not self._sse_task.done():
return
self._sse_task = asyncio.create_task(self._async_sse_loop())
async def async_stop(self) -> None:
"""Stop background tasks owned by the coordinator."""
tasks = [task for task in (self._refresh_task, self._sse_task) if task is not None]
self._refresh_task = None
self._sse_task = None
for task in tasks:
task.cancel()
if tasks:
await asyncio.gather(*tasks, return_exceptions=True)
self._set_sse_enabled(False)
async def async_restart(self) -> None:
"""Reconnect the SSE stream, used after base URL changes."""
await self.async_stop()
await self.async_start()
def _set_sse_enabled(self, enabled: bool) -> None:
if self._sse_enabled == enabled:
return
self._sse_enabled = enabled
self.update_interval = _POLL_INTERVAL_SSE if enabled else _POLL_INTERVAL_FALLBACK
_LOGGER.debug("RelayTV SSE %s for %s", "enabled" if enabled else "disabled", self.api.base_url)
def _schedule_refresh(self) -> None:
if self._refresh_task and not self._refresh_task.done():
return
async def _delayed_refresh() -> None:
try:
await asyncio.sleep(_SSE_REFRESH_DEBOUNCE_SEC)
await self.async_request_refresh()
except asyncio.CancelledError:
raise
except Exception:
_LOGGER.debug("RelayTV SSE-triggered refresh failed", exc_info=True)
self._refresh_task = asyncio.create_task(_delayed_refresh())
async def _async_dispatch_event(self, event_name: str | None, data_lines: list[str]) -> None:
if not data_lines and not event_name:
return
raw = "\n".join(data_lines).strip()
payload: Any
if raw:
try:
payload = json.loads(raw)
except Exception:
_LOGGER.debug("Ignoring non-JSON RelayTV SSE payload for %s: %r", event_name, raw)
return
else:
payload = {}
if not event_name and isinstance(payload, dict):
event_name = str(payload.get("type") or "").strip() or None
if not event_name:
return
if event_name == "status":
if isinstance(payload, dict):
self.async_set_updated_data(payload)
else:
self._schedule_refresh()
return
if event_name == "playback":
if isinstance(payload, dict):
merged = _merge_playback_snapshot(self.data, payload)
if merged is not None:
self.async_set_updated_data(merged)
else:
self._schedule_refresh()
else:
self._schedule_refresh()
return
if event_name in ("queue", "jellyfin"):
self._schedule_refresh()
return
if event_name == "hello":
if not isinstance(self.data, dict) or not self.last_update_success:
self._schedule_refresh()
return
if event_name == "ping":
return
_LOGGER.debug("Ignoring unsupported RelayTV SSE event %s", event_name)
async def _async_sse_loop(self) -> None:
backoff = 1.0
headers = {
"Accept": "text/event-stream",
"Cache-Control": "no-cache",
}
while True:
try:
timeout = aiohttp.ClientTimeout(
total=None,
connect=_SSE_CONNECT_TIMEOUT,
sock_read=_SSE_READ_TIMEOUT,
)
async with self.api.session.get(self.api.url_for("ui/events"), headers=headers, timeout=timeout) as resp:
if resp.status >= 400:
raise aiohttp.ClientResponseError(
resp.request_info,
resp.history,
status=resp.status,
message=f"Unexpected RelayTV SSE response: {resp.status}",
headers=resp.headers,
)
self._set_sse_enabled(True)
backoff = 1.0
event_name: str | None = None
data_lines: list[str] = []
async for raw_line in resp.content:
line = raw_line.decode("utf-8", "ignore").rstrip("\r\n")
if line == "":
await self._async_dispatch_event(event_name, data_lines)
event_name = None
data_lines = []
continue
if line.startswith(":"):
continue
field, _, value = line.partition(":")
if value.startswith(" "):
value = value[1:]
if field == "event":
event_name = value.strip() or None
elif field == "data":
data_lines.append(value)
if event_name or data_lines:
await self._async_dispatch_event(event_name, data_lines)
except asyncio.CancelledError:
raise
except Exception:
_LOGGER.debug("RelayTV SSE loop disconnected for %s", self.api.base_url, exc_info=True)
finally:
self._set_sse_enabled(False)
await asyncio.sleep(backoff)
backoff = min(backoff * 2.0, 30.0)
+6 -1
View File
@@ -4,6 +4,7 @@ This integration targets the RelayTV server API documented in relaytv/docs/API.m
We intentionally prefer the canonical endpoints: We intentionally prefer the canonical endpoints:
- GET /status - GET /status
- GET /ui/events
- POST /play - POST /play
- POST /smart - POST /smart
- POST /enqueue - POST /enqueue
@@ -43,6 +44,10 @@ class RelayTVApi:
base_url: str base_url: str
timeout_s: float = 8.0 timeout_s: float = 8.0
def url_for(self, path: str) -> str:
"""Build an absolute RelayTV URL for a relative API path."""
return _join(self.base_url, path)
async def _request_json( async def _request_json(
self, self,
method: str, method: str,
@@ -50,7 +55,7 @@ class RelayTVApi:
*, *,
json: Optional[dict[str, Any]] = None, json: Optional[dict[str, Any]] = None,
) -> Optional[dict[str, Any]]: ) -> Optional[dict[str, Any]]:
url = _join(self.base_url, path) url = self.url_for(path)
try: try:
async with asyncio.timeout(self.timeout_s): async with asyncio.timeout(self.timeout_s):
async with self.session.request(method, url, json=json) as resp: async with self.session.request(method, url, json=json) as resp: