updating for use with new endpoints
This commit is contained in:
@@ -33,3 +33,7 @@ secrets.yaml
|
|||||||
# OS specific
|
# OS specific
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
|
#Build Tools
|
||||||
|
build-release.sh
|
||||||
|
VERSION
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user