updating for use with new endpoints

This commit is contained in:
customrenovations
2026-02-21 20:31:17 -06:00
parent a0e2431bdf
commit 3204f07947
5 changed files with 160 additions and 242 deletions
+12 -32
View File
@@ -5,8 +5,7 @@ This integration:
2) Exposes a RelayTV media_player entity backed by RelayTV's local HTTP API.
3) Provides services (e.g., relaytv.smart_url) for automations and mobile share flows.
The API layer is implemented defensively with endpoint fallbacks so it can be
adapted to RelayTV deployments that may differ slightly.
The API layer targets RelayTV's canonical endpoints (see RelayTV server docs/API.md).
"""
from __future__ import annotations
@@ -94,47 +93,28 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
if not url:
return
await api.smart_url(url)
await coordinator.async_request_refresh()
async def _handle_play_now(call):
"""Play a URL immediately (maps to RelayTV POST /play).
Note: RelayTV /play clears the queue.
"""
url = (call.data.get("url") or "").strip()
if not url:
return
preserve_current = call.data.get("preserve_current", True)
reason = call.data.get("reason")
await api.play_now(url=url, preserve_current=preserve_current, reason=reason)
use_ytdlp = call.data.get("use_ytdlp")
cec = call.data.get("cec")
await api.play(url=url, use_ytdlp=use_ytdlp, cec=cec)
await coordinator.async_request_refresh()
async def _handle_announce(call):
url = (call.data.get("url") or "").strip()
if not url:
return
preserve_current = call.data.get("preserve_current", True)
await api.play_now(url=url, preserve_current=preserve_current, reason="announcement")
await coordinator.async_request_refresh()
# RelayTV doesn't have a distinct "announce" mode; alias to play_now.
await _handle_play_now(call)
# Register services once (first config entry wins).
if not hass.services.has_service(DOMAIN, SERVICE_SMART_URL):
hass.services.async_register(DOMAIN, SERVICE_SMART_URL, _handle_smart_url)
async def _handle_play_now(call):
url = call.data.get("url") or ""
preserve_current = call.data.get("preserve_current", True)
reason = call.data.get("reason")
title = call.data.get("title")
thumbnail = call.data.get("thumbnail")
if not url:
return
await api.play_now(url=url, preserve_current=preserve_current, reason=reason, title=title, thumbnail=thumbnail)
await coordinator.async_request_refresh()
async def _handle_announce(call):
url = call.data.get("url") or ""
preserve_current = call.data.get("preserve_current", True)
if not url:
return
await api.play_now(url=url, preserve_current=preserve_current, reason="announcement")
await coordinator.async_request_refresh()
if not hass.services.has_service(DOMAIN, SERVICE_PLAY_NOW):
hass.services.async_register(DOMAIN, SERVICE_PLAY_NOW, _handle_play_now)
if not hass.services.has_service(DOMAIN, SERVICE_ANNOUNCE):
@@ -197,4 +177,4 @@ def _register_panel(hass: HomeAssistant, *, path: str, title: str, icon: str, ur
config={"url": url},
require_admin=False,
)
_LOGGER.info("Registered RelayTV Web UI panel at /%s%s", path, url)
_LOGGER.info("Registered RelayTV Web UI panel at /%s%s", path, url)
+35 -18
View File
@@ -63,7 +63,7 @@ def _parse_status(data: Optional[dict[str, Any]]) -> _StatusView:
if not isinstance(data, dict):
return _StatusView()
# Common keys (based on similar local-player APIs)
# Common keys (based on RelayTV docs/API.md)
playing = bool(data.get("playing") or data.get("is_playing") or data.get("play"))
paused = bool(data.get("paused") or data.get("is_paused") or data.get("pause"))
@@ -90,10 +90,24 @@ def _parse_status(data: Optional[dict[str, Any]]) -> _StatusView:
title = title or data.get("title")
url = url or data.get("url")
# Prefer locally cached thumbnails when present (RelayTV serves /thumbs/<id>.jpg)
thumb = None
if isinstance(np, dict):
thumb = np.get("thumbnail") or np.get("thumb") or np.get("image") or np.get("art") or np.get("poster")
thumb = thumb or data.get("thumbnail") or data.get("image") or data.get("art")
thumb = (
np.get("thumbnail_local")
or np.get("thumbnail")
or np.get("thumb")
or np.get("image")
or np.get("art")
or np.get("poster")
)
thumb = (
thumb
or data.get("thumbnail_local")
or data.get("thumbnail")
or data.get("image")
or data.get("art")
)
return _StatusView(
playing=playing,
@@ -139,7 +153,6 @@ class RelayTVMediaPlayer(CoordinatorEntity, MediaPlayerEntity):
| MediaPlayerEntityFeature.PREVIOUS_TRACK
| MediaPlayerEntityFeature.SEEK
| MediaPlayerEntityFeature.VOLUME_SET
| MediaPlayerEntityFeature.VOLUME_MUTE
| MediaPlayerEntityFeature.TURN_ON
| MediaPlayerEntityFeature.TURN_OFF
)
@@ -176,7 +189,8 @@ class RelayTVMediaPlayer(CoordinatorEntity, MediaPlayerEntity):
@property
def is_volume_muted(self) -> Optional[bool]:
return _parse_status(self.coordinator.data).muted
# RelayTV doesn't currently expose mute as a dedicated API.
return None
@property
def media_title(self) -> Optional[str]:
@@ -205,25 +219,30 @@ class RelayTVMediaPlayer(CoordinatorEntity, MediaPlayerEntity):
if t.tzinfo is None:
return t.replace(tzinfo=timezone.utc)
return t
@property
def entity_picture(self) -> Optional[str]:
v = _parse_status(self.coordinator.data)
return _abs_url(self._entry.data.get("base_url", ""), v.thumbnail)
async def async_media_play(self) -> None:
await self._api.command("play")
# RelayTV provides /playback/play with "TV remote" semantics:
# - toggle pause if playing
# - resume closed session if available
# - else play next in queue
await self._api.playback_play()
await self.coordinator.async_request_refresh()
async def async_media_pause(self) -> None:
await self._api.command("pause")
await self._api.pause()
await self.coordinator.async_request_refresh()
async def async_media_stop(self) -> None:
await self._api.command("stop")
await self._api.stop()
await self.coordinator.async_request_refresh()
async def async_media_next_track(self) -> None:
await self._api.command("next")
await self._api.next()
await self.coordinator.async_request_refresh()
async def async_media_previous_track(self) -> None:
@@ -235,21 +254,19 @@ class RelayTVMediaPlayer(CoordinatorEntity, MediaPlayerEntity):
await self.coordinator.async_request_refresh()
async def async_mute_volume(self, mute: bool) -> None:
await self._api.command("mute", value=bool(mute))
await self.coordinator.async_request_refresh()
# Not supported by RelayTV API at this time.
return
async def async_turn_on(self) -> None:
# Power-on maps to RelayTV's preferred play semantics (/playback/play):
# unpause if playing, resume closed session, or start next queued item.
# Same behavior as PLAY.
await self._api.playback_play()
await self.coordinator.async_request_refresh()
async def async_turn_off(self) -> None:
# Map power-off to RelayTV close/quit behavior.
await self._api.command("close")
# Treat "turn off" as stop playback.
await self._api.stop()
await self.coordinator.async_request_refresh()
async def async_media_seek(self, position: float) -> None:
await self._api.command("seek", value=float(position))
await self.coordinator.async_request_refresh()
await self._api.seek_abs(float(position))
await self.coordinator.async_request_refresh()
+94 -182
View File
@@ -1,16 +1,27 @@
"""RelayTV local HTTP API helper.
This is intentionally defensive: RelayTV deployments may expose slightly different
paths depending on version/build. We try a small set of common candidates.
This integration targets the RelayTV server API documented in relaytv/docs/API.md.
We intentionally prefer the canonical endpoints:
- GET /status
- POST /play
- POST /smart
- POST /enqueue
- POST /next
- POST /pause | /resume | /toggle_pause
- POST /seek_abs
- POST /volume
- POST /stop
The wrapper remains defensive around timeouts and JSON parsing.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Iterable, Optional
import asyncio
import logging
from dataclasses import dataclass
from typing import Any, Optional
import aiohttp
@@ -34,16 +45,16 @@ class RelayTVApi:
async def _request_json(
self,
method: str,
url: str,
path: str,
*,
json: Optional[dict[str, Any]] = None,
) -> Optional[dict[str, Any]]:
url = _join(self.base_url, path)
try:
async with asyncio.timeout(self.timeout_s):
async with self.session.request(method, url, json=json) as resp:
if resp.status >= 400:
return None
# Some endpoints may return empty body.
try:
return await resp.json(content_type=None)
except Exception:
@@ -51,161 +62,74 @@ class RelayTVApi:
except Exception:
return None
async def _first_success(
self,
method: str,
paths: Iterable[str],
*,
json: Optional[dict[str, Any]] = None,
) -> Optional[dict[str, Any]]:
for p in paths:
url = _join(self.base_url, p)
data = await self._request_json(method, url, json=json)
if data is not None:
return data
return None
async def get_status(self) -> Optional[dict[str, Any]]:
"""Fetch current playback/status."""
return await self._first_success(
"GET",
(
"status",
"api/status",
"v1/status",
"player/status",
),
)
return await self._request_json("GET", "status")
async def smart_url(self, url: str) -> bool:
"""Smart URL handler used by the HA service relaytv.smart_url.
Goal:
- If something is already playing, try to ENQUEUE (add to queue) first.
- Otherwise, try the server's smart_url endpoint.
- Only as a last resort, fall back to play-now style endpoints.
"""
payload = {"url": url}
status = await self.get_status() or {}
# Determine "currently playing" as defensively as possible.
state = str(status.get("state") or "").lower()
playing_flag = bool(status.get("playing")) and not bool(status.get("paused"))
is_playing = playing_flag or (state == "playing")
if is_playing:
data = await self._first_success(
"POST",
(
"queue/add",
"api/queue/add",
"v1/queue/add",
"enqueue",
"api/enqueue",
"v1/enqueue",
"queue",
"api/queue",
"v1/queue",
),
json=payload,
)
if data is not None:
return True
# Prefer true smart endpoints next.
data = await self._first_success(
"POST",
(
"smart_url",
"api/smart_url",
"v1/smart_url",
"cast/smart_url",
),
json=payload,
)
if data is not None:
return True
# Last resort: endpoints that typically REPLACE current playback.
data = await self._first_success(
"POST",
(
"cast/url",
"play",
"api/play",
"v1/play",
),
json=payload,
)
"""RelayTV one-button behavior (POST /smart)."""
data = await self._request_json("POST", "smart", json={"url": url})
return data is not None
async def command(self, cmd: str, *, value: Optional[Any] = None) -> bool:
"""Send a player control command.
async def play(self, url: str, *, use_ytdlp: bool | None = None, cec: bool | None = None) -> bool:
"""Immediate play; clears queue (POST /play)."""
payload: dict[str, Any] = {"url": url}
if use_ytdlp is not None:
payload["use_ytdlp"] = bool(use_ytdlp)
if cec is not None:
payload["cec"] = bool(cec)
data = await self._request_json("POST", "play", json=payload)
return data is not None
This is aligned to the RelayTV server API:
async def enqueue(self, url: str) -> bool:
"""Add an item to the end of the queue (POST /enqueue)."""
data = await self._request_json("POST", "enqueue", json={"url": url})
return data is not None
- Seek scrubber should be absolute: POST /seek_abs {"sec": <seconds>}
- Volume slider should be absolute: POST /volume {"set": <0-100>}
- Power on / play semantics: POST /playback/play {}
async def next(self) -> bool:
"""Skip to the next queued item (POST /next)."""
data = await self._request_json("POST", "next", json={})
return data is not None
We still keep a couple of legacy fallbacks for older builds.
async def previous(self) -> bool:
"""Go to the previous item (POST /previous)."""
data = await self._request_json("POST", "previous", json={})
return data is not None
async def pause(self) -> bool:
return (await self._request_json("POST", "pause", json={})) is not None
async def resume(self) -> bool:
return (await self._request_json("POST", "resume", json={})) is not None
async def toggle_pause(self) -> bool:
return (await self._request_json("POST", "toggle_pause", json={})) is not None
async def stop(self) -> bool:
return (await self._request_json("POST", "stop", json={})) is not None
async def playback_play(self) -> bool:
"""User-facing Play semantics (POST /playback/play).
RelayTV's server implements "TV remote" behavior here:
- if mpv is running: toggle pause
- else if session is closed: resume
- else: play next queued item
If the endpoint is missing (older servers), fall back to ensure_playing().
"""
cmd = (cmd or "").strip().lower()
# Absolute seek (HA provides absolute seconds)
if cmd == "seek" and value is not None:
return await self.seek_abs(float(value))
# Volume: HA provides 0.0-1.0
if cmd == "volume" and value is not None:
return await self.set_volume(value)
# Preferred play semantics
if cmd == "play":
return await self.playback_play()
# Simple direct endpoints for common commands
direct_paths = {
"pause": ("pause",),
"toggle_pause": ("toggle_pause", "pause"),
"stop": ("stop",),
"close": ("close",),
"next": ("next", "queue/next", "queue/skip"),
"previous": ("previous",),
}
if cmd in direct_paths:
data = await self._first_success("POST", direct_paths[cmd], json={})
return data is not None
# Legacy control-style fallback
payload: dict[str, Any] = {"command": cmd}
if value is not None:
payload["value"] = value
data = await self._first_success("POST", ("control", "api/control", "v1/control"), json=payload)
data = await self._request_json("POST", "playback/play", json={})
if data is not None:
return True
# Legacy "POST /player/<cmd>" or "/<cmd>"
data = await self._first_success("POST", (f"player/{cmd}", cmd), json={} if value is None else {"value": value})
if data is not None:
return True
_LOGGER.debug("RelayTV command failed: %s value=%s", cmd, value)
return False
return await self.ensure_playing()
async def seek_abs(self, sec: float) -> bool:
"""Seek to an absolute position in seconds (RelayTV: POST /seek_abs)."""
"""Seek to an absolute position in seconds (POST /seek_abs)."""
try:
sec_f = float(sec)
except Exception:
return False
data = await self._first_success("POST", ("seek_abs",), json={"sec": sec_f})
if data is not None:
return True
# Very old fallback (some builds used /seek with sec as absolute)
data = await self._first_success("POST", ("seek",), json={"sec": sec_f})
data = await self._request_json("POST", "seek_abs", json={"sec": sec_f})
return data is not None
async def set_volume(self, level: Any) -> bool:
@@ -214,6 +138,7 @@ class RelayTVApi:
v = float(level)
except Exception:
return False
# Normalize
if v <= 1.0:
pct = v * 100.0
@@ -221,48 +146,35 @@ class RelayTVApi:
pct = v
pct = max(0.0, min(200.0, float(pct)))
# RelayTV expects {"set": <float>} (and supports {"delta": <float>} for relative changes)
# RelayTV expects {"set": <number>}
for val in (pct, round(pct), int(round(pct))):
data = await self._first_success("POST", ("volume",), json={"set": val})
data = await self._request_json("POST", "volume", json={"set": val})
if data is not None:
return True
# fallback via control (rare)
data = await self._first_success("POST", ("control", "api/control", "v1/control"), json={"command": "volume", "set": pct})
return data is not None
return False
async def playback_play(self) -> bool:
"""RelayTV's preferred 'Play' semantics: POST /playback/play."""
data = await self._first_success("POST", ("playback/play",), json={})
if data is not None:
async def ensure_playing(self) -> bool:
"""Best-effort play semantics for Home Assistant.
RelayTV does not currently expose a single "resume session or play next" endpoint.
We emulate expected behavior:
- If paused -> POST /resume
- Else if already playing -> success (noop)
- Else if queue has items -> POST /next
"""
st = await self.get_status() or {}
if bool(st.get("paused")):
return await self.resume()
if bool(st.get("playing")):
return True
# fallback
data = await self._first_success("POST", ("resume", "play"), json={})
return data is not None
async def previous(self) -> bool:
"""Go back (server decides restart vs history)."""
data = await self._first_success("POST", ("previous",), json={})
return data is not None
try:
if int(st.get("queue_length") or 0) > 0:
return await self.next()
except Exception:
pass
async def play_now(
self,
url: str,
preserve_current: bool = True,
reason: Optional[str] = None,
title: Optional[str] = None,
thumbnail: Optional[str] = None,
) -> bool:
"""Interrupt-play a URL immediately, optionally preserving current into queue front."""
payload: dict[str, Any] = {
"url": url,
"preserve_current": bool(preserve_current),
}
if reason:
payload["reason"] = reason
if title:
payload["title"] = title
if thumbnail:
payload["thumbnail"] = thumbnail
data = await self._first_success("POST", ("play_now",), json=payload)
return data is not None
_LOGGER.debug("ensure_playing: nothing to resume or play")
return False
+15 -10
View File
@@ -1,6 +1,6 @@
smart_url:
name: Smart play URL
description: Resolve and play a shared URL in RelayTV (mobile share flow).
description: Resolve and play a shared URL in RelayTV (RelayTV POST /smart).
fields:
url:
required: true
@@ -9,36 +9,41 @@ smart_url:
text:
play_now:
name: Play now (interrupt)
description: Play a URL immediately and (optionally) preserve whatever is currently playing to the front of the queue.
name: Play now
description: Play a URL immediately (RelayTV POST /play). This clears the queue.
fields:
url:
required: true
example: "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
selector:
text:
preserve_current:
use_ytdlp:
required: false
default: true
selector:
boolean:
reason:
cec:
required: false
example: "announcement"
default: false
selector:
text:
boolean:
announce:
name: Announce (interrupt)
description: Convenience wrapper for play_now with reason=announcement.
name: Announce
description: Alias for play_now. (RelayTV does not currently implement a distinct announcement mode.)
fields:
url:
required: true
example: "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
selector:
text:
preserve_current:
use_ytdlp:
required: false
default: true
selector:
boolean:
cec:
required: false
default: false
selector:
boolean: