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
+4
View File
@@ -33,3 +33,7 @@ secrets.yaml
# OS specific # OS specific
.DS_Store .DS_Store
Thumbs.db Thumbs.db
#Build Tools
build-release.sh
VERSION
+12 -32
View File
@@ -5,8 +5,7 @@ This integration:
2) Exposes a RelayTV media_player entity backed by RelayTV's local HTTP API. 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. 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 The API layer targets RelayTV's canonical endpoints (see RelayTV server docs/API.md).
adapted to RelayTV deployments that may differ slightly.
""" """
from __future__ import annotations from __future__ import annotations
@@ -94,47 +93,28 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
if not url: if not url:
return return
await api.smart_url(url) await api.smart_url(url)
await coordinator.async_request_refresh()
async def _handle_play_now(call): 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() url = (call.data.get("url") or "").strip()
if not url: if not url:
return return
preserve_current = call.data.get("preserve_current", True) use_ytdlp = call.data.get("use_ytdlp")
reason = call.data.get("reason") cec = call.data.get("cec")
await api.play_now(url=url, preserve_current=preserve_current, reason=reason) await api.play(url=url, use_ytdlp=use_ytdlp, cec=cec)
await coordinator.async_request_refresh() await coordinator.async_request_refresh()
async def _handle_announce(call): async def _handle_announce(call):
url = (call.data.get("url") or "").strip() # RelayTV doesn't have a distinct "announce" mode; alias to play_now.
if not url: await _handle_play_now(call)
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()
# Register services once (first config entry wins). # Register services once (first config entry wins).
if not hass.services.has_service(DOMAIN, SERVICE_SMART_URL): if not hass.services.has_service(DOMAIN, SERVICE_SMART_URL):
hass.services.async_register(DOMAIN, SERVICE_SMART_URL, _handle_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): if not hass.services.has_service(DOMAIN, SERVICE_PLAY_NOW):
hass.services.async_register(DOMAIN, SERVICE_PLAY_NOW, _handle_play_now) hass.services.async_register(DOMAIN, SERVICE_PLAY_NOW, _handle_play_now)
if not hass.services.has_service(DOMAIN, SERVICE_ANNOUNCE): 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}, config={"url": url},
require_admin=False, 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): if not isinstance(data, dict):
return _StatusView() 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")) 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")) 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") title = title or data.get("title")
url = url or data.get("url") url = url or data.get("url")
# Prefer locally cached thumbnails when present (RelayTV serves /thumbs/<id>.jpg)
thumb = None thumb = None
if isinstance(np, dict): 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 = thumb or data.get("thumbnail") or data.get("image") or data.get("art") 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( return _StatusView(
playing=playing, playing=playing,
@@ -139,7 +153,6 @@ class RelayTVMediaPlayer(CoordinatorEntity, MediaPlayerEntity):
| MediaPlayerEntityFeature.PREVIOUS_TRACK | MediaPlayerEntityFeature.PREVIOUS_TRACK
| MediaPlayerEntityFeature.SEEK | MediaPlayerEntityFeature.SEEK
| MediaPlayerEntityFeature.VOLUME_SET | MediaPlayerEntityFeature.VOLUME_SET
| MediaPlayerEntityFeature.VOLUME_MUTE
| MediaPlayerEntityFeature.TURN_ON | MediaPlayerEntityFeature.TURN_ON
| MediaPlayerEntityFeature.TURN_OFF | MediaPlayerEntityFeature.TURN_OFF
) )
@@ -176,7 +189,8 @@ class RelayTVMediaPlayer(CoordinatorEntity, MediaPlayerEntity):
@property @property
def is_volume_muted(self) -> Optional[bool]: 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 @property
def media_title(self) -> Optional[str]: def media_title(self) -> Optional[str]:
@@ -205,25 +219,30 @@ class RelayTVMediaPlayer(CoordinatorEntity, MediaPlayerEntity):
if t.tzinfo is None: if t.tzinfo is None:
return t.replace(tzinfo=timezone.utc) return t.replace(tzinfo=timezone.utc)
return t return t
@property @property
def entity_picture(self) -> Optional[str]: def entity_picture(self) -> Optional[str]:
v = _parse_status(self.coordinator.data) v = _parse_status(self.coordinator.data)
return _abs_url(self._entry.data.get("base_url", ""), v.thumbnail) return _abs_url(self._entry.data.get("base_url", ""), v.thumbnail)
async def async_media_play(self) -> None: 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() await self.coordinator.async_request_refresh()
async def async_media_pause(self) -> None: async def async_media_pause(self) -> None:
await self._api.command("pause") await self._api.pause()
await self.coordinator.async_request_refresh() await self.coordinator.async_request_refresh()
async def async_media_stop(self) -> None: async def async_media_stop(self) -> None:
await self._api.command("stop") await self._api.stop()
await self.coordinator.async_request_refresh() await self.coordinator.async_request_refresh()
async def async_media_next_track(self) -> None: async def async_media_next_track(self) -> None:
await self._api.command("next") await self._api.next()
await self.coordinator.async_request_refresh() await self.coordinator.async_request_refresh()
async def async_media_previous_track(self) -> None: async def async_media_previous_track(self) -> None:
@@ -235,21 +254,19 @@ class RelayTVMediaPlayer(CoordinatorEntity, MediaPlayerEntity):
await self.coordinator.async_request_refresh() await self.coordinator.async_request_refresh()
async def async_mute_volume(self, mute: bool) -> None: async def async_mute_volume(self, mute: bool) -> None:
await self._api.command("mute", value=bool(mute)) # Not supported by RelayTV API at this time.
await self.coordinator.async_request_refresh() return
async def async_turn_on(self) -> None: async def async_turn_on(self) -> None:
# Power-on maps to RelayTV's preferred play semantics (/playback/play): # Same behavior as PLAY.
# unpause if playing, resume closed session, or start next queued item.
await self._api.playback_play() await self._api.playback_play()
await self.coordinator.async_request_refresh() await self.coordinator.async_request_refresh()
async def async_turn_off(self) -> None: async def async_turn_off(self) -> None:
# Map power-off to RelayTV close/quit behavior. # Treat "turn off" as stop playback.
await self._api.command("close") await self._api.stop()
await self.coordinator.async_request_refresh() await self.coordinator.async_request_refresh()
async def async_media_seek(self, position: float) -> None: async def async_media_seek(self, position: float) -> None:
await self._api.command("seek", value=float(position)) await self._api.seek_abs(float(position))
await self.coordinator.async_request_refresh() await self.coordinator.async_request_refresh()
+94 -182
View File
@@ -1,16 +1,27 @@
"""RelayTV local HTTP API helper. """RelayTV local HTTP API helper.
This is intentionally defensive: RelayTV deployments may expose slightly different This integration targets the RelayTV server API documented in relaytv/docs/API.md.
paths depending on version/build. We try a small set of common candidates. 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 __future__ import annotations
from dataclasses import dataclass
from typing import Any, Iterable, Optional
import asyncio import asyncio
import logging import logging
from dataclasses import dataclass
from typing import Any, Optional
import aiohttp import aiohttp
@@ -34,16 +45,16 @@ class RelayTVApi:
async def _request_json( async def _request_json(
self, self,
method: str, method: str,
url: str, path: str,
*, *,
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)
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:
if resp.status >= 400: if resp.status >= 400:
return None return None
# Some endpoints may return empty body.
try: try:
return await resp.json(content_type=None) return await resp.json(content_type=None)
except Exception: except Exception:
@@ -51,161 +62,74 @@ class RelayTVApi:
except Exception: except Exception:
return None 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]]: async def get_status(self) -> Optional[dict[str, Any]]:
"""Fetch current playback/status.""" """Fetch current playback/status."""
return await self._first_success( return await self._request_json("GET", "status")
"GET",
(
"status",
"api/status",
"v1/status",
"player/status",
),
)
async def smart_url(self, url: str) -> bool: async def smart_url(self, url: str) -> bool:
"""Smart URL handler used by the HA service relaytv.smart_url. """RelayTV one-button behavior (POST /smart)."""
data = await self._request_json("POST", "smart", json={"url": 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,
)
return data is not None return data is not None
async def play(self, url: str, *, use_ytdlp: bool | None = None, cec: bool | None = None) -> bool:
async def command(self, cmd: str, *, value: Optional[Any] = None) -> bool: """Immediate play; clears queue (POST /play)."""
"""Send a player control command. 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>} async def next(self) -> bool:
- Volume slider should be absolute: POST /volume {"set": <0-100>} """Skip to the next queued item (POST /next)."""
- Power on / play semantics: POST /playback/play {} 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() data = await self._request_json("POST", "playback/play", json={})
# 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)
if data is not None: if data is not None:
return True return True
return await self.ensure_playing()
# 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
async def seek_abs(self, sec: float) -> bool: 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: try:
sec_f = float(sec) sec_f = float(sec)
except Exception: except Exception:
return False return False
data = await self._first_success("POST", ("seek_abs",), json={"sec": sec_f}) data = await self._request_json("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})
return data is not None return data is not None
async def set_volume(self, level: Any) -> bool: async def set_volume(self, level: Any) -> bool:
@@ -214,6 +138,7 @@ class RelayTVApi:
v = float(level) v = float(level)
except Exception: except Exception:
return False return False
# Normalize # Normalize
if v <= 1.0: if v <= 1.0:
pct = v * 100.0 pct = v * 100.0
@@ -221,48 +146,35 @@ class RelayTVApi:
pct = v pct = v
pct = max(0.0, min(200.0, float(pct))) 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))): 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: if data is not None:
return True return True
# fallback via control (rare) return False
data = await self._first_success("POST", ("control", "api/control", "v1/control"), json={"command": "volume", "set": pct})
return data is not None
async def playback_play(self) -> bool: async def ensure_playing(self) -> bool:
"""RelayTV's preferred 'Play' semantics: POST /playback/play.""" """Best-effort play semantics for Home Assistant.
data = await self._first_success("POST", ("playback/play",), json={})
if data is not None: 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 return True
# fallback
data = await self._first_success("POST", ("resume", "play"), json={})
return data is not None
async def previous(self) -> bool: try:
"""Go back (server decides restart vs history).""" if int(st.get("queue_length") or 0) > 0:
data = await self._first_success("POST", ("previous",), json={}) return await self.next()
return data is not None except Exception:
pass
async def play_now( _LOGGER.debug("ensure_playing: nothing to resume or play")
self, return False
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
+15 -10
View File
@@ -1,6 +1,6 @@
smart_url: smart_url:
name: Smart play 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: fields:
url: url:
required: true required: true
@@ -9,36 +9,41 @@ smart_url:
text: text:
play_now: play_now:
name: Play now (interrupt) name: Play now
description: Play a URL immediately and (optionally) preserve whatever is currently playing to the front of the queue. description: Play a URL immediately (RelayTV POST /play). This clears the queue.
fields: fields:
url: url:
required: true required: true
example: "https://www.youtube.com/watch?v=dQw4w9WgXcQ" example: "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
selector: selector:
text: text:
preserve_current: use_ytdlp:
required: false required: false
default: true default: true
selector: selector:
boolean: boolean:
reason: cec:
required: false required: false
example: "announcement" default: false
selector: selector:
text: boolean:
announce: announce:
name: Announce (interrupt) name: Announce
description: Convenience wrapper for play_now with reason=announcement. description: Alias for play_now. (RelayTV does not currently implement a distinct announcement mode.)
fields: fields:
url: url:
required: true required: true
example: "https://www.youtube.com/watch?v=dQw4w9WgXcQ" example: "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
selector: selector:
text: text:
preserve_current: use_ytdlp:
required: false required: false
default: true default: true
selector: selector:
boolean: boolean:
cec:
required: false
default: false
selector:
boolean: