Initial commit for relaytv-ha
This commit is contained in:
@@ -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
@@ -0,0 +1,13 @@
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# Environments
|
||||
.venv/
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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.**
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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:
|
||||
@@ -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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user