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