From f08f7ac167e5aff0cde968b2a20f07a8f4f9a32c Mon Sep 17 00:00:00 2001 From: customrenovations Date: Fri, 20 Feb 2026 12:22:56 -0600 Subject: [PATCH] Initial commit for relaytv-ha --- .github/workflows/release.yml | 22 ++ .gitignore | 13 + CHANGELOG.md | 19 ++ README.md | 53 ++++ custom_components/relaytv/README.md | 143 ++++++++++ custom_components/relaytv/__init__.py | 200 +++++++++++++ custom_components/relaytv/config_flow.py | 84 ++++++ custom_components/relaytv/const.py | 23 ++ custom_components/relaytv/coordinator.py | 32 +++ custom_components/relaytv/manifest.json | 16 ++ custom_components/relaytv/media_player.py | 255 +++++++++++++++++ custom_components/relaytv/relaytv_api.py | 268 ++++++++++++++++++ custom_components/relaytv/services.yaml | 44 +++ custom_components/relaytv/strings.json | 30 ++ .../relaytv/translations/en.json | 29 ++ hacs.json | 8 + 16 files changed, 1239 insertions(+) create mode 100644 .github/workflows/release.yml create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 README.md create mode 100644 custom_components/relaytv/README.md create mode 100644 custom_components/relaytv/__init__.py create mode 100644 custom_components/relaytv/config_flow.py create mode 100644 custom_components/relaytv/const.py create mode 100644 custom_components/relaytv/coordinator.py create mode 100644 custom_components/relaytv/manifest.json create mode 100644 custom_components/relaytv/media_player.py create mode 100644 custom_components/relaytv/relaytv_api.py create mode 100644 custom_components/relaytv/services.yaml create mode 100644 custom_components/relaytv/strings.json create mode 100644 custom_components/relaytv/translations/en.json create mode 100644 hacs.json diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..742cab8 --- /dev/null +++ b/.github/workflows/release.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ecd87bf --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# Environments +.venv/ +env/ +venv/ +ENV/ + +# OS files +.DS_Store diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..a9c1764 --- /dev/null +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..d50919b --- /dev/null +++ b/README.md @@ -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 diff --git a/custom_components/relaytv/README.md b/custom_components/relaytv/README.md new file mode 100644 index 0000000..ae6b15b --- /dev/null +++ b/custom_components/relaytv/README.md @@ -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.** diff --git a/custom_components/relaytv/__init__.py b/custom_components/relaytv/__init__.py new file mode 100644 index 0000000..a1d988e --- /dev/null +++ b/custom_components/relaytv/__init__.py @@ -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) \ No newline at end of file diff --git a/custom_components/relaytv/config_flow.py b/custom_components/relaytv/config_flow.py new file mode 100644 index 0000000..c23ee82 --- /dev/null +++ b/custom_components/relaytv/config_flow.py @@ -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) diff --git a/custom_components/relaytv/const.py b/custom_components/relaytv/const.py new file mode 100644 index 0000000..bb23cd2 --- /dev/null +++ b/custom_components/relaytv/const.py @@ -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" diff --git a/custom_components/relaytv/coordinator.py b/custom_components/relaytv/coordinator.py new file mode 100644 index 0000000..e2e8c1d --- /dev/null +++ b/custom_components/relaytv/coordinator.py @@ -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 diff --git a/custom_components/relaytv/manifest.json b/custom_components/relaytv/manifest.json new file mode 100644 index 0000000..abc7dec --- /dev/null +++ b/custom_components/relaytv/manifest.json @@ -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" +} \ No newline at end of file diff --git a/custom_components/relaytv/media_player.py b/custom_components/relaytv/media_player.py new file mode 100644 index 0000000..4d8019d --- /dev/null +++ b/custom_components/relaytv/media_player.py @@ -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() \ No newline at end of file diff --git a/custom_components/relaytv/relaytv_api.py b/custom_components/relaytv/relaytv_api.py new file mode 100644 index 0000000..f4b2373 --- /dev/null +++ b/custom_components/relaytv/relaytv_api.py @@ -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": } + - 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/" or "/" + 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": } (and supports {"delta": } 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 diff --git a/custom_components/relaytv/services.yaml b/custom_components/relaytv/services.yaml new file mode 100644 index 0000000..24b77dc --- /dev/null +++ b/custom_components/relaytv/services.yaml @@ -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: diff --git a/custom_components/relaytv/strings.json b/custom_components/relaytv/strings.json new file mode 100644 index 0000000..33fefa7 --- /dev/null +++ b/custom_components/relaytv/strings.json @@ -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)" + } + } + } + } +} diff --git a/custom_components/relaytv/translations/en.json b/custom_components/relaytv/translations/en.json new file mode 100644 index 0000000..c18f620 --- /dev/null +++ b/custom_components/relaytv/translations/en.json @@ -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)" + } + } + } + } +} diff --git a/hacs.json b/hacs.json new file mode 100644 index 0000000..4f575f5 --- /dev/null +++ b/hacs.json @@ -0,0 +1,8 @@ +{ + "name": "RelayTV Panel", + "content_in_root": false, + "render_readme": true, + "homeassistant": "2023.10.0", + "zip_release": true, + "filename": "relaytv.zip" +} \ No newline at end of file