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
- 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:
- Play, pause, stop
- Next and previous
@@ -101,7 +104,7 @@ data:
- 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`.
- 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
+6 -1
View File
@@ -5,7 +5,11 @@ This integration provides a RelayTV `media_player` entity, RelayTV service actio
## Implemented Behavior
- `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.
- RelayTV services are registered from `services.yaml`:
- `smart_url`
@@ -41,5 +45,6 @@ This integration provides a RelayTV `media_player` entity, RelayTV service actio
- Overlay requires `text` or `image_url`.
- Snapshot requires active playback on the RelayTV server.
- 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.
+3
View File
@@ -321,6 +321,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
coordinator.async_add_listener(_save_resume_position)
await coordinator.async_config_entry_first_refresh()
await coordinator.async_start()
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
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)
entry_data = hass.data.get(DOMAIN, {}).pop(entry.entry_id, None)
if entry_data:
await entry_data[DATA_COORDINATOR].async_stop()
for unsub in entry_data.get("mapping_unsubs", []):
unsub()
@@ -483,6 +485,7 @@ async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> Non
store = _get_entry_data(hass, entry.entry_id)
if store and base_url:
store[DATA_API].base_url = base_url
await store[DATA_COORDINATOR].async_restart()
for unsub in store.get("mapping_unsubs", []):
unsub()
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 datetime import timedelta
import asyncio
import json
import logging
from datetime import timedelta
from typing import Any
import aiohttp
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
@@ -12,21 +17,196 @@ from .relaytv_api import RelayTVApi
_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:
super().__init__(
hass,
_LOGGER,
name="RelayTV status",
update_interval=timedelta(seconds=3),
update_interval=_POLL_INTERVAL_FALLBACK,
)
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()
if data is None:
raise UpdateFailed("Unable to fetch RelayTV status")
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:
- GET /status
- GET /ui/events
- POST /play
- POST /smart
- POST /enqueue
@@ -43,6 +44,10 @@ class RelayTVApi:
base_url: str
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(
self,
method: str,
@@ -50,7 +55,7 @@ class RelayTVApi:
*,
json: Optional[dict[str, Any]] = None,
) -> Optional[dict[str, Any]]:
url = _join(self.base_url, path)
url = self.url_for(path)
try:
async with asyncio.timeout(self.timeout_s):
async with self.session.request(method, url, json=json) as resp: