Initial commit for relaytv-ha

This commit is contained in:
customrenovations
2026-02-20 12:25:32 -06:00
commit f08f7ac167
16 changed files with 1239 additions and 0 deletions
+22
View File
@@ -0,0 +1,22 @@
name: Release
on:
push:
tags:
- "v*"
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Create relaytv.zip for HACS
run: |
cd custom_components
zip -r ../relaytv.zip relaytv
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
files: relaytv.zip
+13
View File
@@ -0,0 +1,13 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# Environments
.venv/
env/
venv/
ENV/
# OS files
.DS_Store
+19
View File
@@ -0,0 +1,19 @@
## 0.2.3
- Fix HA seek bar by providing media_position_updated_at.
- Add entity_picture support (thumbnail) when RelayTV status includes artwork fields.
- Improve seek/volume command payload compatibility.
# Changelog
## 0.2.2
- Fix `relaytv.smart_url` behavior: prefer enqueue/smart endpoints and avoid replacing current playback when possible.
## v0.2.1
- Fix OptionsFlow crash (`config_entry` is a read-only property in recent Home Assistant versions)
## v0.2.0
- Add `media_player.relaytv` entity backed by RelayTV HTTP API
- Add `relaytv.smart_url` service for automations / Companion App sharing
## v0.1.0
- Initial HACS-ready release of **RelayTV Panel**
- Adds a sidebar iframe panel pointing to a configurable RelayTV base URL
+53
View File
@@ -0,0 +1,53 @@
# RelayTV Panel (Home Assistant)
A minimal Home Assistant integration that adds a **RelayTV** sidebar panel (iframe) pointing at your RelayTV instance.
- **Domain:** `relaytv`
- **Type:** Sidebar panel (iframe)
- **Entities:** None (RelayTV remains the control surface)
## Install via HACS (Custom Repository)
1. In Home Assistant, go to **HACS → Integrations**
2. Open the menu (⋮) → **Custom repositories**
3. Add this repository URL, category **Integration**
4. Install **RelayTV Panel**
5. Restart Home Assistant
6. Add the integration: **Settings → Devices & Services → Add Integration → RelayTV Panel**
7. Enter your RelayTV base URL (example: `http://relaytv-host:8787`)
## Manual Install
Copy `custom_components/relaytv` into:
```
/config/custom_components/relaytv
```
Restart Home Assistant, then add the integration from the UI.
## Configuration
During setup you provide:
- **RelayTV base URL** (required)
Options allow:
- Sidebar title
- Sidebar icon (MDI)
- Sidebar path (URL slug)
## Notes
- This integration does **not** create a `media_player` entity.
- It embeds the RelayTV UI at `/ui` via iframe.
## Versioning
This repo uses semantic versioning.
Current version: **v0.1.0**
## License
TBD
+143
View File
@@ -0,0 +1,143 @@
# RelayTV -- Home Assistant Integration
The RelayTV Home Assistant integration adds a sidebar panel that embeds
the RelayTV web UI directly inside Home Assistant.
This integration does **not** create media entities or mirror playback
state into HA.\
RelayTV remains the authoritative playback engine and UI.
------------------------------------------------------------------------
## What This Integration Does
- Adds a dedicated **RelayTV** sidebar panel
- Embeds the RelayTV `/ui` interface via iframe
- Allows control from desktop or mobile HA apps
- Keeps RelayTV fully self-contained
------------------------------------------------------------------------
## Requirements
- A running RelayTV instance accessible from Home Assistant
- RelayTV reachable via HTTP (e.g. `http://relaytv-host:8787`)
- Home Assistant 2023.x or newer recommended
------------------------------------------------------------------------
## Installation (Manual)
1. Copy the `relaytv_webui` folder into:
```{=html}
<!-- -->
```
/config/custom_components/
So it becomes:
/config/custom_components/relaytv_webui/
2. Restart Home Assistant.
3. Go to:
```{=html}
<!-- -->
```
Settings → Devices & Services → Add Integration
4. Search for **RelayTV Web UI Panel**.
5. Enter the base URL where RelayTV is reachable from Home Assistant.
Example:
http://relaytv-host:8787
------------------------------------------------------------------------
## Configuration Options
After installation, you can configure:
Option Description
--------------- ----------------------------
Sidebar title Display name in HA sidebar
Sidebar icon Any valid MDI icon
Sidebar path URL slug used in HA
------------------------------------------------------------------------
## Example Configuration
Base URL:
http://192.168.1.50:8787
Custom sidebar path:
relaytv
Resulting HA path:
http://homeassistant.local:8123/relaytv
------------------------------------------------------------------------
## How It Works
The integration registers a built-in Home Assistant **iframe panel**.
No polling, no entities, no media_player integration.
RelayTV's own API and state model remain independent and
server-authoritative.
------------------------------------------------------------------------
## Security Notes
- RelayTV should only be exposed on trusted networks.
- If accessing via HTTPS reverse proxy, use the proxied URL as the
base URL.
- Ensure CORS and authentication policies match your deployment
environment.
------------------------------------------------------------------------
## Recommended Deployment Pattern
For maximum reliability:
- Run RelayTV in Docker
- Bind-mount `/data` for persistent queue/history
- Use stable and beta containers during upgrades
- Point HA at the stable instance
------------------------------------------------------------------------
## Roadmap
Planned future enhancements:
- Optional HA media_player entity bridge
- Service calls for play/enqueue
- WebSocket event push support
- HACS compatibility
------------------------------------------------------------------------
## Support
If the panel fails to load:
1. Verify RelayTV is reachable from the HA container
2. Confirm the base URL is correct
3. Check HA logs for integration load errors
4. Confirm no mixed HTTP/HTTPS blocking issues
------------------------------------------------------------------------
**RelayTV --- A local-first media runtime for your television.**
+200
View File
@@ -0,0 +1,200 @@
"""RelayTV integration.
This integration:
1) Registers a Home Assistant sidebar iframe panel that embeds the RelayTV web UI.
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.
"""
from __future__ import annotations
import logging
from urllib.parse import urlparse
from homeassistant.core import HomeAssistant
from homeassistant.config_entries import ConfigEntry
from homeassistant.components import frontend
from homeassistant.helpers import aiohttp_client
from .const import (
CONF_BASE_URL,
CONF_PANEL_ICON,
CONF_PANEL_PATH,
CONF_PANEL_TITLE,
DEFAULT_PANEL_ICON,
DEFAULT_PANEL_PATH,
DEFAULT_PANEL_TITLE,
DATA_API,
DATA_COORDINATOR,
DOMAIN,
PLATFORMS,
SERVICE_SMART_URL,
SERVICE_PLAY_NOW,
SERVICE_ANNOUNCE,
)
from .relaytv_api import RelayTVApi
from .coordinator import RelayTVCoordinator
_LOGGER = logging.getLogger(__name__)
def _normalize_base_url(raw: str) -> str:
"""Normalize user input into a URL safe for iframe embedding."""
raw = (raw or "").strip()
if not raw:
return ""
# Allow users to paste host:port, add scheme.
if "://" not in raw:
raw = f"http://{raw}"
# Basic parse/normalize; keep path if user provided one.
p = urlparse(raw)
if not p.netloc:
return raw
# Remove trailing slash to avoid double slashes when HA appends.
normalized = f"{p.scheme}://{p.netloc}{p.path}".rstrip("/")
if p.query:
normalized += f"?{p.query}"
return normalized
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up RelayTV from a config entry."""
base_url = _normalize_base_url(entry.data.get(CONF_BASE_URL, ""))
if not base_url:
_LOGGER.error("RelayTV base URL is empty; panel will not be registered")
return False
# Store runtime objects
hass.data.setdefault(DOMAIN, {})
session = aiohttp_client.async_get_clientsession(hass)
api = RelayTVApi(session=session, base_url=base_url)
coordinator = RelayTVCoordinator(hass=hass, api=api)
hass.data[DOMAIN][entry.entry_id] = {DATA_API: api, DATA_COORDINATOR: coordinator}
# Prime coordinator (non-fatal if it fails; entity will show unavailable).
await coordinator.async_config_entry_first_refresh()
title = entry.options.get(CONF_PANEL_TITLE, DEFAULT_PANEL_TITLE)
icon = entry.options.get(CONF_PANEL_ICON, DEFAULT_PANEL_ICON)
path = entry.options.get(CONF_PANEL_PATH, DEFAULT_PANEL_PATH)
_register_panel(hass, path=path, title=title, icon=icon, url=base_url)
# Register platforms/entities
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
# Register services
async def _handle_smart_url(call):
url = (call.data.get("url") or "").strip()
if not url:
return
await api.smart_url(url)
async def _handle_play_now(call):
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)
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()
# 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):
hass.services.async_register(DOMAIN, SERVICE_ANNOUNCE, _handle_announce)
entry.async_on_unload(entry.add_update_listener(_async_update_listener))
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload RelayTV Web UI config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
path = entry.options.get(CONF_PANEL_PATH, DEFAULT_PANEL_PATH)
try:
frontend.async_remove_panel(hass, path)
except Exception: # pragma: no cover
_LOGGER.debug("Panel removal failed (it may not exist)", exc_info=True)
# Remove services only if this is the last entry.
hass.data.get(DOMAIN, {}).pop(entry.entry_id, None)
if not hass.data.get(DOMAIN):
try:
hass.services.async_remove(DOMAIN, SERVICE_SMART_URL)
except Exception:
pass
return unload_ok
async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Handle options updates by re-registering the panel."""
base_url = _normalize_base_url(entry.data.get(CONF_BASE_URL, ""))
title = entry.options.get(CONF_PANEL_TITLE, DEFAULT_PANEL_TITLE)
icon = entry.options.get(CONF_PANEL_ICON, DEFAULT_PANEL_ICON)
path = entry.options.get(CONF_PANEL_PATH, DEFAULT_PANEL_PATH)
try:
frontend.async_remove_panel(hass, path)
except Exception:
pass
_register_panel(hass, path=path, title=title, icon=icon, url=base_url)
# Update API base_url if needed
store = hass.data.get(DOMAIN, {}).get(entry.entry_id)
if store and base_url:
store[DATA_API].base_url = base_url
def _register_panel(hass: HomeAssistant, *, path: str, title: str, icon: str, url: str) -> None:
"""Register the sidebar iframe panel."""
# We use the built-in iframe panel. Keyword args protect against HA signature drift.
frontend.async_register_built_in_panel(
hass,
component_name="iframe",
sidebar_title=title,
sidebar_icon=icon,
frontend_url_path=path,
config={"url": url},
require_admin=False,
)
_LOGGER.info("Registered RelayTV Web UI panel at /%s%s", path, url)
+84
View File
@@ -0,0 +1,84 @@
"""Config flow for RelayTV Web UI panel."""
from __future__ import annotations
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.core import callback
from .const import (
CONF_BASE_URL,
CONF_PANEL_ICON,
CONF_PANEL_PATH,
CONF_PANEL_TITLE,
DEFAULT_PANEL_ICON,
DEFAULT_PANEL_PATH,
DEFAULT_PANEL_TITLE,
DOMAIN,
)
class RelayTVWebUIConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for RelayTV Web UI panel."""
VERSION = 1
async def async_step_user(self, user_input=None):
errors = {}
if user_input is not None:
base_url = (user_input.get(CONF_BASE_URL) or "").strip()
if not base_url:
errors["base"] = "missing_base_url"
else:
# Single instance is usually sufficient; users can duplicate by cloning the folder/domain if needed.
await self.async_set_unique_id(DOMAIN)
self._abort_if_unique_id_configured()
return self.async_create_entry(title=DEFAULT_PANEL_TITLE, data={CONF_BASE_URL: base_url})
schema = vol.Schema(
{
vol.Required(CONF_BASE_URL): str,
}
)
return self.async_show_form(step_id="user", data_schema=schema, errors=errors)
@staticmethod
@callback
def async_get_options_flow(config_entry):
return RelayTVWebUIOptionsFlow(config_entry)
class RelayTVWebUIOptionsFlow(config_entries.OptionsFlow):
"""Handle options for RelayTV Web UI panel."""
def __init__(self, config_entry: config_entries.ConfigEntry) -> None:
# NOTE: In modern Home Assistant, OptionsFlow exposes a read-only
# `config_entry` property, so we cannot assign to it.
# Store it on a private attribute instead.
self._config_entry = config_entry
async def async_step_init(self, user_input=None):
if user_input is not None:
return self.async_create_entry(title="", data=user_input)
schema = vol.Schema(
{
vol.Optional(
CONF_PANEL_TITLE,
default=self._config_entry.options.get(CONF_PANEL_TITLE, DEFAULT_PANEL_TITLE),
): str,
vol.Optional(
CONF_PANEL_ICON,
default=self._config_entry.options.get(CONF_PANEL_ICON, DEFAULT_PANEL_ICON),
): str,
vol.Optional(
CONF_PANEL_PATH,
default=self._config_entry.options.get(CONF_PANEL_PATH, DEFAULT_PANEL_PATH),
): str,
}
)
return self.async_show_form(step_id="init", data_schema=schema)
+23
View File
@@ -0,0 +1,23 @@
"""Constants for the RelayTV Web UI panel integration."""
DOMAIN = "relaytv"
PLATFORMS: list[str] = ["media_player"]
CONF_BASE_URL = "base_url"
CONF_PANEL_TITLE = "panel_title"
CONF_PANEL_ICON = "panel_icon"
CONF_PANEL_PATH = "panel_path"
DEFAULT_PANEL_TITLE = "RelayTV"
DEFAULT_PANEL_ICON = "mdi:cast"
DEFAULT_PANEL_PATH = "relaytv"
# Services
SERVICE_SMART_URL = "smart_url"
SERVICE_PLAY_NOW = "play_now"
SERVICE_ANNOUNCE = "announce"
# Data keys
DATA_COORDINATOR = "coordinator"
DATA_API = "api"
+32
View File
@@ -0,0 +1,32 @@
"""Coordinator for RelayTV polling."""
from __future__ import annotations
from datetime import timedelta
import logging
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .relaytv_api import RelayTVApi
_LOGGER = logging.getLogger(__name__)
class RelayTVCoordinator(DataUpdateCoordinator[dict]):
"""Poll RelayTV for its current status."""
def __init__(self, hass: HomeAssistant, api: RelayTVApi) -> None:
super().__init__(
hass,
_LOGGER,
name="RelayTV status",
update_interval=timedelta(seconds=3),
)
self.api = api
async def _async_update_data(self) -> dict:
data = await self.api.get_status()
if data is None:
raise UpdateFailed("Unable to fetch RelayTV status")
return data
+16
View File
@@ -0,0 +1,16 @@
{
"codeowners": [
"@your-github-handle"
],
"config_flow": true,
"dependencies": [
"frontend"
],
"documentation": "https://github.com/your-org/relaytv",
"domain": "relaytv",
"iot_class": "local_polling",
"issue_tracker": "https://github.com/your-org/relaytv/issues",
"name": "RelayTV",
"requirements": [],
"version": "0.3.6"
}
+255
View File
@@ -0,0 +1,255 @@
"""Media player platform for RelayTV."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timezone
from urllib.parse import urlparse
from typing import Any, Optional
from homeassistant.components.media_player import MediaPlayerEntity
from homeassistant.components.media_player.const import (
MediaPlayerEntityFeature,
MediaPlayerState,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DATA_API, DATA_COORDINATOR, DOMAIN
def _num(v: Any) -> Optional[float]:
try:
if v is None:
return None
return float(v)
except Exception:
return None
def _abs_url(base: str, maybe: Optional[str]) -> Optional[str]:
if not maybe:
return None
s = str(maybe)
# already absolute
try:
p = urlparse(s)
if p.scheme in ("http", "https"):
return s
except Exception:
pass
base = (base or "").rstrip("/")
s2 = s.lstrip("/")
return f"{base}/{s2}" if base and s2 else (base or None)
@dataclass
class _StatusView:
playing: bool = False
paused: bool = False
volume: Optional[float] = None # 0..1
muted: Optional[bool] = None
position: Optional[float] = None
duration: Optional[float] = None
title: Optional[str] = None
url: Optional[str] = None
thumbnail: Optional[str] = None
def _parse_status(data: Optional[dict[str, Any]]) -> _StatusView:
"""Best-effort parse across possible RelayTV status shapes."""
if not isinstance(data, dict):
return _StatusView()
# Common keys (based on similar local-player APIs)
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"))
vol = data.get("volume")
vol_f = _num(vol)
# Some APIs use 0-100
if vol_f is not None and vol_f > 1.0:
vol_f = max(0.0, min(1.0, vol_f / 100.0))
muted = data.get("muted")
if muted is None:
muted = data.get("mute")
muted_b = None if muted is None else bool(muted)
position = _num(data.get("position") or data.get("pos") or data.get("time"))
duration = _num(data.get("duration") or data.get("len") or data.get("total"))
np = data.get("now_playing") or data.get("media") or {}
title = None
url = None
if isinstance(np, dict):
title = np.get("title") or np.get("name")
url = np.get("url") or np.get("input")
title = title or data.get("title")
url = url or data.get("url")
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")
return _StatusView(
playing=playing,
paused=paused,
volume=vol_f,
muted=muted_b,
position=position,
duration=duration,
title=title,
url=url,
thumbnail=thumb,
)
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
store = hass.data[DOMAIN][entry.entry_id]
coordinator = store[DATA_COORDINATOR]
api = store[DATA_API]
async_add_entities([RelayTVMediaPlayer(entry, coordinator, api)])
class RelayTVMediaPlayer(CoordinatorEntity, MediaPlayerEntity):
"""RelayTV as a HA media_player."""
_attr_has_entity_name = True
_attr_name = "RelayTV"
def __init__(self, entry: ConfigEntry, coordinator, api) -> None:
super().__init__(coordinator)
self._entry = entry
self._api = api
self._attr_unique_id = f"{entry.entry_id}_player"
self._attr_supported_features = (
MediaPlayerEntityFeature.PLAY
| MediaPlayerEntityFeature.PAUSE
| MediaPlayerEntityFeature.STOP
| MediaPlayerEntityFeature.NEXT_TRACK
| MediaPlayerEntityFeature.PREVIOUS_TRACK
| MediaPlayerEntityFeature.SEEK
| MediaPlayerEntityFeature.VOLUME_SET
| MediaPlayerEntityFeature.VOLUME_MUTE
| MediaPlayerEntityFeature.TURN_ON
| MediaPlayerEntityFeature.TURN_OFF
)
@property
def state(self) -> Optional[MediaPlayerState]:
v = _parse_status(self.coordinator.data)
if v.playing and not v.paused:
return MediaPlayerState.PLAYING
if v.paused:
return MediaPlayerState.PAUSED
# If we have a title/url but not playing, treat as idle.
if v.title or v.url:
return MediaPlayerState.IDLE
return MediaPlayerState.OFF
@property
def available(self) -> bool:
return self.coordinator.last_update_success
@property
def volume_level(self) -> Optional[float]:
# HA expects 0.0-1.0. RelayTV reports 0-100 (or None when closed).
v = _parse_status(self.coordinator.data).volume
try:
if v is None:
return 0.0
vf = float(v)
if vf > 1.0:
vf = vf / 100.0
return max(0.0, min(1.0, vf))
except Exception:
return 0.0
@property
def is_volume_muted(self) -> Optional[bool]:
return _parse_status(self.coordinator.data).muted
@property
def media_title(self) -> Optional[str]:
return _parse_status(self.coordinator.data).title
@property
def media_content_id(self) -> Optional[str]:
return _parse_status(self.coordinator.data).url
@property
def media_duration(self) -> Optional[float]:
return _parse_status(self.coordinator.data).duration
@property
def media_position(self) -> Optional[float]:
return _parse_status(self.coordinator.data).position
@property
def media_position_updated_at(self) -> Optional[datetime]:
# Helps HA render a moving seek bar while playing.
# Use coordinator timestamp if available; otherwise fall back to "now" (UTC).
t = getattr(self.coordinator, "last_update_success_time", None)
if t is None:
return datetime.now(timezone.utc)
# Ensure timezone-aware
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")
await self.coordinator.async_request_refresh()
async def async_media_pause(self) -> None:
await self._api.command("pause")
await self.coordinator.async_request_refresh()
async def async_media_stop(self) -> None:
await self._api.command("stop")
await self.coordinator.async_request_refresh()
async def async_media_next_track(self) -> None:
await self._api.command("next")
await self.coordinator.async_request_refresh()
async def async_media_previous_track(self) -> None:
await self._api.previous()
await self.coordinator.async_request_refresh()
async def async_set_volume_level(self, volume: float) -> None:
await self._api.set_volume(max(0.0, min(1.0, float(volume))))
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()
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.
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")
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()
+268
View File
@@ -0,0 +1,268 @@
"""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.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Iterable, Optional
import asyncio
import logging
import aiohttp
_LOGGER = logging.getLogger(__name__)
def _join(base: str, path: str) -> str:
base = (base or "").rstrip("/")
path = (path or "").lstrip("/")
return f"{base}/{path}" if path else base
@dataclass
class RelayTVApi:
"""Small wrapper around RelayTV HTTP endpoints."""
session: aiohttp.ClientSession
base_url: str
timeout_s: float = 8.0
async def _request_json(
self,
method: str,
url: str,
*,
json: Optional[dict[str, Any]] = None,
) -> Optional[dict[str, Any]]:
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:
return {}
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",
),
)
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,
)
return data is not None
async def command(self, cmd: str, *, value: Optional[Any] = None) -> bool:
"""Send a player control command.
This is aligned to the RelayTV server API:
- 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 {}
We still keep a couple of legacy fallbacks for older builds.
"""
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)
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
async def seek_abs(self, sec: float) -> bool:
"""Seek to an absolute position in seconds (RelayTV: 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})
return data is not None
async def set_volume(self, level: Any) -> bool:
"""Set volume from HA's 0.0-1.0 slider to RelayTV's 0-100 scale."""
try:
v = float(level)
except Exception:
return False
# Normalize
if v <= 1.0:
pct = v * 100.0
else:
pct = v
pct = max(0.0, min(200.0, float(pct)))
# RelayTV expects {"set": <float>} (and supports {"delta": <float>} for relative changes)
for val in (pct, round(pct), int(round(pct))):
data = await self._first_success("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
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:
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
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
+44
View File
@@ -0,0 +1,44 @@
smart_url:
name: Smart play URL
description: Resolve and play a shared URL in RelayTV (mobile share flow).
fields:
url:
required: true
example: "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
selector:
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.
fields:
url:
required: true
example: "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
selector:
text:
preserve_current:
required: false
default: true
selector:
boolean:
reason:
required: false
example: "announcement"
selector:
text:
announce:
name: Announce (interrupt)
description: Convenience wrapper for play_now with reason=announcement.
fields:
url:
required: true
example: "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
selector:
text:
preserve_current:
required: false
default: true
selector:
boolean:
+30
View File
@@ -0,0 +1,30 @@
{
"title": "RelayTV Panel",
"config": {
"step": {
"user": {
"title": "Connect to RelayTV",
"description": "Enter the base URL where RelayTV is reachable from Home Assistant (example: http://relaytv-host:8787).",
"data": {
"base_url": "RelayTV base URL"
}
}
},
"error": {
"missing_base_url": "Please enter a RelayTV base URL."
}
},
"options": {
"step": {
"init": {
"title": "Panel options",
"description": "Customize how the RelayTV panel appears in the Home Assistant sidebar.",
"data": {
"panel_title": "Sidebar title",
"panel_icon": "Sidebar icon (MDI)",
"panel_path": "Sidebar path (URL slug)"
}
}
}
}
}
@@ -0,0 +1,29 @@
{
"config": {
"step": {
"user": {
"title": "Connect to RelayTV",
"description": "Enter the base URL where RelayTV is reachable from Home Assistant (example: http://relaytv-host:8787).",
"data": {
"base_url": "RelayTV base URL"
}
}
},
"error": {
"missing_base_url": "Please enter a RelayTV base URL."
}
},
"options": {
"step": {
"init": {
"title": "Panel options",
"description": "Customize how the RelayTV panel appears in the Home Assistant sidebar.",
"data": {
"panel_title": "Sidebar title",
"panel_icon": "Sidebar icon (MDI)",
"panel_path": "Sidebar path (URL slug)"
}
}
}
}
}
+8
View File
@@ -0,0 +1,8 @@
{
"name": "RelayTV Panel",
"content_in_root": false,
"render_readme": true,
"homeassistant": "2023.10.0",
"zip_release": true,
"filename": "relaytv.zip"
}