diff --git a/README.md b/README.md index d50919b..301f46b 100644 --- a/README.md +++ b/README.md @@ -1,53 +1,326 @@ -# RelayTV Panel (Home Assistant) +# RelayTV – Home Assistant Integration -A minimal Home Assistant integration that adds a **RelayTV** sidebar panel (iframe) pointing at your RelayTV instance. +RelayTV transforms Home Assistant into a **programmable local media automation engine**. -- **Domain:** `relaytv` -- **Type:** Sidebar panel (iframe) -- **Entities:** None (RelayTV remains the control surface) +It exposes your RelayTV server as a `media_player` (plus services), supports queue-based playback, and makes it easy to drive **local HDMI playback** from automations (doorbells, sensors, schedules, scenes, and AI). -## Install via HACS (Custom Repository) +> Tip: The YAML below uses the `relaytv.*` service names as examples. If your integration exposes slightly different names/fields, keep the **automation patterns** and adjust the service calls accordingly. -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 +# 🚀 What RelayTV Enables Today +## 🎬 Media Player Control +- `media_player` entity support +- Play / pause / stop / seek / volume +- Queue management +- Resume playback support (where implemented) +- Local HDMI playback +- yt-dlp powered “smart URL” stream resolution + +--- + +# 🔵 Practical Automation Use Cases (Available Now) + +- 🚪 Doorbell / camera pop on the TV +- ⏰ Morning video alarm clock +- 🌙 Bedtime routines (relaxing queue + auto stop) +- 🏠 Home/Away/Movie mode scenes that change what’s on screen +- 📺 Queue-based “experiences” (workouts, parties, timers, intros) + +--- + +# 🟢 Creative & Fun Automation Ideas + +- 👻 Late-night motion jump scares +- 🧟 Halloween mode (seasonal triggers + ambient loops) +- 🐶 Dog cam on bark detection +- 🎉 Birthday surprise mode +- 🎮 Easter-egg triggers (sensor patterns launch hidden clips) + +--- + +# 🔴 Advanced Home Automation Scenarios + +- 🤖 AI-powered announcements (TTS + relevant clip) +- 🌧 Weather-based media (radar, warnings, checklist videos) +- 📈 Energy monitoring visualizations (play alert/graph clip) +- 🛡 “Command center” camera rotation on alarm events +- 📰 AI daily digest queue (headlines + summaries) + +--- + +# 🧠 Core Services (Examples) + +| Service | Description | +|--------|-------------| +| `relaytv.play_now` | Immediately plays a URL | +| `relaytv.enqueue` | Adds URL to queue | +| `relaytv.smart_url` | Resolves URL via yt-dlp (when applicable) | +| `relaytv.clear_queue` | Clears current queue | +| `relaytv.seek` | Seek to a time position | +| `relaytv.volume_set` | Adjust volume | + +--- + +# ✅ Automation Examples (YAML) + +## 1) Doorbell → Show Front Door Camera (RTSP) + +```yaml +alias: RelayTV - Doorbell Camera Pop +mode: single +trigger: + - platform: state + entity_id: binary_sensor.front_door_ding + to: "on" +action: + - service: relaytv.play_now + target: + entity_id: media_player.relaytv_living_room + data: + url: "rtsp://user:pass@10.0.55.10:554/stream1" + - service: relaytv.volume_set + target: + entity_id: media_player.relaytv_living_room + data: + volume_level: 0.55 +``` + +## 2) Morning Video Alarm Clock (Playlist + Lights) + +```yaml +alias: RelayTV - Morning Video Alarm +mode: single +trigger: + - platform: time + at: "06:45:00" +condition: + - condition: state + entity_id: person.mark + state: "home" +action: + - service: light.turn_on + target: + entity_id: light.bedroom + data: + brightness_pct: 10 + - delay: "00:00:05" + - service: relaytv.play_now + target: + entity_id: media_player.relaytv_bedroom + data: + url: "https://www.youtube.com/playlist?list=YOUR_PLAYLIST_ID" + - service: relaytv.volume_set + target: + entity_id: media_player.relaytv_bedroom + data: + volume_level: 0.25 + - delay: "00:05:00" + - service: light.turn_on + target: + entity_id: light.bedroom + data: + brightness_pct: 45 +``` + +## 3) Late-Night Motion → Jump Scare (Fun Mode) + +```yaml +alias: RelayTV - Late Night Jump Scare +mode: single +trigger: + - platform: state + entity_id: binary_sensor.hall_motion + to: "on" +condition: + - condition: time + after: "01:00:00" + before: "04:00:00" +action: + - service: relaytv.volume_set + target: + entity_id: media_player.relaytv_living_room + data: + volume_level: 0.85 + - service: light.turn_on + target: + entity_id: light.hallway + data: + brightness_pct: 100 + - service: relaytv.play_now + target: + entity_id: media_player.relaytv_living_room + data: + url: "https://www.youtube.com/watch?v=YOUR_JUMPSCARE_CLIP" + - delay: "00:00:12" + - service: light.turn_off + target: + entity_id: light.hallway +``` + +## 4) Alarm Trigger → “Command Center” Camera Rotation (Queue) + +```yaml +alias: RelayTV - Alarm Command Center +mode: restart +trigger: + - platform: state + entity_id: alarm_control_panel.house + to: "triggered" +action: + - service: relaytv.clear_queue + target: + entity_id: media_player.relaytv_living_room + - service: relaytv.enqueue + target: + entity_id: media_player.relaytv_living_room + data: + url: "rtsp://user:pass@10.0.55.10:554/front" + - service: relaytv.enqueue + target: + entity_id: media_player.relaytv_living_room + data: + url: "rtsp://user:pass@10.0.55.10:554/driveway" + - service: relaytv.enqueue + target: + entity_id: media_player.relaytv_living_room + data: + url: "rtsp://user:pass@10.0.55.10:554/backyard" + - service: relaytv.play_now + target: + entity_id: media_player.relaytv_living_room + data: + url: "rtsp://user:pass@10.0.55.10:554/front" +``` + +## 5) Party Mode Script (Intro Clip + Playlist) + +Create this as a **Script** in HA (Settings → Automations & Scenes → Scripts). + +```yaml +party_mode_relaytv: + alias: Party Mode (RelayTV) + mode: single + sequence: + - service: relaytv.clear_queue + target: + entity_id: media_player.relaytv_living_room + - service: relaytv.enqueue + target: + entity_id: media_player.relaytv_living_room + data: + url: "https://www.youtube.com/watch?v=YOUR_INTRO_CLIP" + - service: relaytv.enqueue + target: + entity_id: media_player.relaytv_living_room + data: + url: "https://www.youtube.com/playlist?list=YOUR_PARTY_PLAYLIST" + - service: relaytv.volume_set + target: + entity_id: media_player.relaytv_living_room + data: + volume_level: 0.65 + - service: relaytv.play_now + target: + entity_id: media_player.relaytv_living_room + data: + url: "https://www.youtube.com/watch?v=YOUR_INTRO_CLIP" +``` + +## 6) “AI Announcement” Pattern (TTS + Video) + +This example uses HA TTS to speak, then plays a related clip. +(You can swap TTS for Assist, Piper, cloud TTS, etc.) + +```yaml +alias: RelayTV - AI Announcement Pattern +mode: single +trigger: + - platform: state + entity_id: binary_sensor.garage_door_open_too_long + to: "on" +action: + - service: tts.speak + data: + media_player_entity_id: media_player.kitchen_speaker + message: "Garage door has been open for a while." + - service: relaytv.play_now + target: + entity_id: media_player.relaytv_living_room + data: + url: "https://www.youtube.com/watch?v=YOUR_ALERT_CLIP" +``` + +## 7) Energy Spike → Alert Clip + Reduce Loads + +```yaml +alias: RelayTV - Energy Spike Alert +mode: single +trigger: + - platform: numeric_state + entity_id: sensor.home_power_kw + above: 8.0 + for: "00:02:00" +action: + - service: relaytv.play_now + target: + entity_id: media_player.relaytv_living_room + data: + url: "https://www.youtube.com/watch?v=YOUR_POWER_ALERT_CLIP" + - service: switch.turn_off + target: + entity_id: + - switch.space_heater + - switch.shop_fan +``` + +--- + +# 🛣 Roadmap (Future Capabilities) + +## 🔔 Interrupt + Resume Service +Temporarily interrupt playback for alerts, then auto-resume prior content. + +## 🪟 Overlay Mode +Display notification banners without stopping playback. + +## 🔄 Multi-Screen Sync +Synchronize playback across multiple RelayTV servers (perfect for whole-home announcements). + +## 🖼 Snapshot Previews +Capture/preview before switching content (helps with camera pops). + +## 🎛 Sensor → Stream Mapping UI +Map motion/doorbell sensors to streams directly in the integration options UI (no YAML required). + +## 🧠 AI Clip Generator +Generate short clips (image + TTS + theme) for alerts, seasons, and events. + +## 📢 Smart Alert Replacement +Insert custom “house announcements” during downtime or between queued items. + +--- + +# 📦 Installation + +## HACS +1. Add as Custom Repository (Integration) +2. Install +3. Restart Home Assistant +4. Add Integration +5. Configure server address + +## Manual Copy `custom_components/relaytv` into: -``` +```text /config/custom_components/relaytv ``` -Restart Home Assistant, then add the integration from the UI. +Restart Home Assistant. -## 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 +--- +# 🔐 License TBD diff --git a/custom_components/relaytv/__init__.py b/custom_components/relaytv/__init__.py index bba6cab..ab5e8f3 100644 --- a/custom_components/relaytv/__init__.py +++ b/custom_components/relaytv/__init__.py @@ -1,43 +1,36 @@ -"""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 targets RelayTV's canonical endpoints (see RelayTV server docs/API.md). -""" +"""RelayTV integration.""" 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 homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ENTITY_ID +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.helpers import aiohttp_client, entity_registry as er +from homeassistant.helpers.storage import Store from .const import ( CONF_BASE_URL, - CONF_PANEL_ICON, - CONF_PANEL_PATH, - CONF_PANEL_TITLE, + CONF_PANEL_ENABLED, + CONF_PANEL_TARGET_ENTRY_ID, + DATA_API, + DATA_COORDINATOR, + DATA_PANEL_SETTINGS, + DATA_STORE, DEFAULT_PANEL_ICON, DEFAULT_PANEL_PATH, DEFAULT_PANEL_TITLE, - DATA_API, - DATA_COORDINATOR, DOMAIN, PLATFORMS, - SERVICE_SMART_URL, - SERVICE_PLAY_NOW, SERVICE_ANNOUNCE, + SERVICE_PLAY_NOW, + SERVICE_SMART_URL, ) - -from .relaytv_api import RelayTVApi from .coordinator import RelayTVCoordinator +from .relaytv_api import RelayTVApi _LOGGER = logging.getLogger(__name__) @@ -47,72 +40,188 @@ def _normalize_base_url(raw: str) -> str: 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: + parsed = urlparse(raw) + if not parsed.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}" + normalized = f"{parsed.scheme}://{parsed.netloc}{parsed.path}".rstrip("/") + if parsed.query: + normalized += f"?{parsed.query}" return normalized +async def async_setup(hass: HomeAssistant, config: dict) -> bool: + """Set up the RelayTV domain.""" + hass.data.setdefault(DOMAIN, {}) + return True + + +async def _async_ensure_settings(hass: HomeAssistant) -> dict: + domain_data = hass.data.setdefault(DOMAIN, {}) + if DATA_STORE not in domain_data: + domain_data[DATA_STORE] = Store(hass, 1, f"{DOMAIN}_panel_settings") + if DATA_PANEL_SETTINGS not in domain_data: + domain_data[DATA_PANEL_SETTINGS] = await domain_data[DATA_STORE].async_load() or { + CONF_PANEL_ENABLED: True, + CONF_PANEL_TARGET_ENTRY_ID: None, + } + return domain_data[DATA_PANEL_SETTINGS] + + +async def _async_save_settings(hass: HomeAssistant) -> None: + data = hass.data.setdefault(DOMAIN, {}) + await data[DATA_STORE].async_save(data[DATA_PANEL_SETTINGS]) + + +def _get_entry_data(hass: HomeAssistant, entry_id: str) -> dict | None: + return hass.data.get(DOMAIN, {}).get(entry_id) + + +def _fallback_entry_id(hass: HomeAssistant) -> str | None: + for entry_id, value in hass.data.get(DOMAIN, {}).items(): + if isinstance(value, dict) and DATA_API in value: + return entry_id + return None + + +def _resolve_entry_id_for_call(hass: HomeAssistant, call: ServiceCall) -> str | None: + entity_ids: list[str] = [] + raw_entity = call.data.get(CONF_ENTITY_ID) + if isinstance(raw_entity, str): + entity_ids = [raw_entity] + elif isinstance(raw_entity, list): + entity_ids = [item for item in raw_entity if isinstance(item, str)] + + registry = er.async_get(hass) + + if not entity_ids: + device_id = call.data.get("device_id") + device_ids = [device_id] if isinstance(device_id, str) else device_id + if isinstance(device_ids, list): + for item in device_ids: + if not isinstance(item, str): + continue + for reg_entry in er.async_entries_for_device(registry, item): + if reg_entry.entity_id.startswith("media_player."): + entity_ids.append(reg_entry.entity_id) + + for entity_id in entity_ids: + reg_entry = registry.async_get(entity_id) + if reg_entry and _get_entry_data(hass, reg_entry.config_entry_id): + return reg_entry.config_entry_id + + panel_target = hass.data.get(DOMAIN, {}).get(DATA_PANEL_SETTINGS, {}).get(CONF_PANEL_TARGET_ENTRY_ID) + if panel_target and _get_entry_data(hass, panel_target): + return panel_target + return _fallback_entry_id(hass) + + +def _register_panel(hass: HomeAssistant, *, path: str, title: str, icon: str, url: str) -> None: + 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, + ) + + +def _async_unregister_panel(hass: HomeAssistant) -> None: + try: + frontend.async_remove_panel(hass, DEFAULT_PANEL_PATH) + except Exception: + _LOGGER.debug("Panel removal failed (it may not exist)", exc_info=True) + + +async def _async_update_panel(hass: HomeAssistant) -> None: + settings = await _async_ensure_settings(hass) + _async_unregister_panel(hass) + if not settings.get(CONF_PANEL_ENABLED, True): + return + + target_entry_id = settings.get(CONF_PANEL_TARGET_ENTRY_ID) + target = _get_entry_data(hass, target_entry_id) if target_entry_id else None + if target is None: + fallback_id = _fallback_entry_id(hass) + if not fallback_id: + return + settings[CONF_PANEL_TARGET_ENTRY_ID] = fallback_id + await _async_save_settings(hass) + target = _get_entry_data(hass, fallback_id) + target_entry_id = fallback_id + + if not target: + return + + url = target[DATA_API].base_url + _register_panel( + hass, + path=DEFAULT_PANEL_PATH, + title=DEFAULT_PANEL_TITLE, + icon=DEFAULT_PANEL_ICON, + url=url, + ) + _LOGGER.info("Registered RelayTV panel to entry %s (%s)", target_entry_id, url) + + +async def _async_set_default_sidebar_target(hass: HomeAssistant, entry: ConfigEntry) -> None: + settings = await _async_ensure_settings(hass) + settings[CONF_PANEL_TARGET_ENTRY_ID] = entry.entry_id + await _async_save_settings(hass) + + 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") + _LOGGER.error("RelayTV base URL is empty") return False - # Store runtime objects - hass.data.setdefault(DOMAIN, {}) + await _async_ensure_settings(hass) + 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} + hass.data.setdefault(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): + await _async_set_default_sidebar_target(hass, entry) + await _async_update_panel(hass) + + async def _handle_smart_url(call: ServiceCall): url = (call.data.get("url") or "").strip() if not url: return - await api.smart_url(url) - await coordinator.async_request_refresh() + entry_id = _resolve_entry_id_for_call(hass, call) + store = _get_entry_data(hass, entry_id) if entry_id else None + if not store: + return + await store[DATA_API].smart_url(url) + await store[DATA_COORDINATOR].async_request_refresh() - async def _handle_play_now(call): - """Play a URL immediately (maps to RelayTV POST /play). - - Note: RelayTV /play clears the queue. - """ + async def _handle_play_now(call: ServiceCall): url = (call.data.get("url") or "").strip() if not url: return - use_ytdlp = call.data.get("use_ytdlp") - cec = call.data.get("cec") - await api.play(url=url, use_ytdlp=use_ytdlp, cec=cec) - await coordinator.async_request_refresh() + entry_id = _resolve_entry_id_for_call(hass, call) + store = _get_entry_data(hass, entry_id) if entry_id else None + if not store: + return + await store[DATA_API].play( + url=url, + use_ytdlp=call.data.get("use_ytdlp"), + cec=call.data.get("cec"), + ) + await store[DATA_COORDINATOR].async_request_refresh() - async def _handle_announce(call): - # RelayTV doesn't have a distinct "announce" mode; alias to play_now. + async def _handle_announce(call: ServiceCall): await _handle_play_now(call) - # 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) if not hass.services.has_service(DOMAIN, SERVICE_PLAY_NOW): @@ -125,56 +234,36 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Unload RelayTV Web UI config entry.""" + """Unload RelayTV 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 + settings = await _async_ensure_settings(hass) + if settings.get(CONF_PANEL_TARGET_ENTRY_ID) == entry.entry_id: + replacement_id = _fallback_entry_id(hass) + settings[CONF_PANEL_TARGET_ENTRY_ID] = replacement_id + await _async_save_settings(hass) + _LOGGER.info( + "RelayTV sidebar target removed (%s); switched to %s", + entry.entry_id, + replacement_id, + ) + + if not any(cfg_entry.entry_id in hass.data.get(DOMAIN, {}) for cfg_entry in hass.config_entries.async_entries(DOMAIN)): + _async_unregister_panel(hass) + for service_name in (SERVICE_SMART_URL, SERVICE_PLAY_NOW, SERVICE_ANNOUNCE): + if hass.services.has_service(DOMAIN, service_name): + hass.services.async_remove(DOMAIN, service_name) + return unload_ok + + await _async_update_panel(hass) return unload_ok async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Handle options updates by re-registering the panel.""" + """Handle entry updates.""" 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) + store = _get_entry_data(hass, 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) + await _async_update_panel(hass) diff --git a/custom_components/relaytv/config_flow.py b/custom_components/relaytv/config_flow.py index c23ee82..b92b479 100644 --- a/custom_components/relaytv/config_flow.py +++ b/custom_components/relaytv/config_flow.py @@ -2,23 +2,43 @@ from __future__ import annotations +from urllib.parse import urlparse + import voluptuous as vol from homeassistant import config_entries +from homeassistant.const import CONF_NAME from homeassistant.core import callback +from homeassistant.helpers.storage import Store from .const import ( CONF_BASE_URL, - CONF_PANEL_ICON, - CONF_PANEL_PATH, - CONF_PANEL_TITLE, - DEFAULT_PANEL_ICON, - DEFAULT_PANEL_PATH, + CONF_PANEL_ENABLED, + CONF_PANEL_TARGET_ENTRY_ID, + CONF_SERVER_NAME, + DATA_PANEL_SETTINGS, + DATA_STORE, DEFAULT_PANEL_TITLE, DOMAIN, ) +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 "" + if "://" not in raw: + raw = f"http://{raw}" + parsed = urlparse(raw) + if not parsed.netloc: + return raw + normalized = f"{parsed.scheme}://{parsed.netloc}{parsed.path}".rstrip("/") + if parsed.query: + normalized += f"?{parsed.query}" + return normalized + + class RelayTVWebUIConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for RelayTV Web UI panel.""" @@ -28,18 +48,24 @@ class RelayTVWebUIConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors = {} if user_input is not None: - base_url = (user_input.get(CONF_BASE_URL) or "").strip() + base_url = _normalize_base_url(user_input.get(CONF_BASE_URL, "")) + name = (user_input.get(CONF_SERVER_NAME) or "").strip() if not base_url: errors["base"] = "missing_base_url" + elif not name: + errors["base"] = "missing_name" else: - # Single instance is usually sufficient; users can duplicate by cloning the folder/domain if needed. - await self.async_set_unique_id(DOMAIN) + await self.async_set_unique_id(base_url) self._abort_if_unique_id_configured() - return self.async_create_entry(title=DEFAULT_PANEL_TITLE, data={CONF_BASE_URL: base_url}) + return self.async_create_entry( + title=name, + data={CONF_BASE_URL: base_url, CONF_NAME: name}, + ) schema = vol.Schema( { - vol.Required(CONF_BASE_URL): str, + vol.Required(CONF_BASE_URL, default="http://localhost:8787"): str, + vol.Required(CONF_SERVER_NAME, default=DEFAULT_PANEL_TITLE): str, } ) @@ -55,29 +81,44 @@ 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): + settings_store = Store(self.hass, 1, f"{DOMAIN}_panel_settings") + settings = await settings_store.async_load() or {} + + entries = self.hass.config_entries.async_entries(DOMAIN) + choices = {entry.entry_id: entry.title for entry in entries} + + current_target = settings.get(CONF_PANEL_TARGET_ENTRY_ID) + if current_target not in choices and choices: + current_target = entries[-1].entry_id + if user_input is not None: - return self.async_create_entry(title="", data=user_input) + chosen_target = user_input.get(CONF_PANEL_TARGET_ENTRY_ID) + if chosen_target not in choices and choices: + chosen_target = entries[-1].entry_id + + updated = { + CONF_PANEL_ENABLED: bool(user_input.get(CONF_PANEL_ENABLED, True)), + CONF_PANEL_TARGET_ENTRY_ID: chosen_target, + } + await settings_store.async_save(updated) + self.hass.data.setdefault(DOMAIN, {})[DATA_STORE] = settings_store + self.hass.data[DOMAIN][DATA_PANEL_SETTINGS] = updated + await self.hass.config_entries.async_reload(self._config_entry.entry_id) + return self.async_create_entry(title="", data={}) 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, + vol.Required( + CONF_PANEL_ENABLED, + default=settings.get(CONF_PANEL_ENABLED, True), + ): bool, + vol.Required( + CONF_PANEL_TARGET_ENTRY_ID, + default=current_target, + ): vol.In(choices), } ) diff --git a/custom_components/relaytv/const.py b/custom_components/relaytv/const.py index bb23cd2..37c0c39 100644 --- a/custom_components/relaytv/const.py +++ b/custom_components/relaytv/const.py @@ -5,9 +5,12 @@ DOMAIN = "relaytv" PLATFORMS: list[str] = ["media_player"] CONF_BASE_URL = "base_url" +CONF_SERVER_NAME = "server_name" CONF_PANEL_TITLE = "panel_title" CONF_PANEL_ICON = "panel_icon" CONF_PANEL_PATH = "panel_path" +CONF_PANEL_ENABLED = "panel_enabled" +CONF_PANEL_TARGET_ENTRY_ID = "panel_target_entry_id" DEFAULT_PANEL_TITLE = "RelayTV" DEFAULT_PANEL_ICON = "mdi:cast" @@ -21,3 +24,5 @@ SERVICE_ANNOUNCE = "announce" # Data keys DATA_COORDINATOR = "coordinator" DATA_API = "api" +DATA_STORE = "store" +DATA_PANEL_SETTINGS = "panel_settings" diff --git a/custom_components/relaytv/media_player.py b/custom_components/relaytv/media_player.py index b308d4c..3e342fc 100644 --- a/custom_components/relaytv/media_player.py +++ b/custom_components/relaytv/media_player.py @@ -13,6 +13,7 @@ from homeassistant.components.media_player.const import ( MediaPlayerState, ) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -137,13 +138,14 @@ class RelayTVMediaPlayer(CoordinatorEntity, MediaPlayerEntity): """RelayTV as a HA media_player.""" _attr_has_entity_name = True - _attr_name = "RelayTV" + _attr_name = None 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_name = entry.data.get(CONF_NAME, entry.title) self._attr_supported_features = ( MediaPlayerEntityFeature.PLAY diff --git a/custom_components/relaytv/services.yaml b/custom_components/relaytv/services.yaml index 19781ca..86ac187 100644 --- a/custom_components/relaytv/services.yaml +++ b/custom_components/relaytv/services.yaml @@ -1,6 +1,12 @@ smart_url: name: Smart play URL description: Resolve and play a shared URL in RelayTV (RelayTV POST /smart). + target: + entity: + domain: media_player + integration: relaytv + device: + integration: relaytv fields: url: required: true @@ -11,6 +17,12 @@ smart_url: play_now: name: Play now description: Play a URL immediately (RelayTV POST /play). This clears the queue. + target: + entity: + domain: media_player + integration: relaytv + device: + integration: relaytv fields: url: required: true @@ -31,6 +43,12 @@ play_now: announce: name: Announce description: Alias for play_now. (RelayTV does not currently implement a distinct announcement mode.) + target: + entity: + domain: media_player + integration: relaytv + device: + integration: relaytv fields: url: required: true diff --git a/custom_components/relaytv/strings.json b/custom_components/relaytv/strings.json index 33fefa7..cbb5932 100644 --- a/custom_components/relaytv/strings.json +++ b/custom_components/relaytv/strings.json @@ -4,25 +4,27 @@ "step": { "user": { "title": "Connect to RelayTV", - "description": "Enter the base URL where RelayTV is reachable from Home Assistant (example: http://relaytv-host:8787).", + "description": "Add a RelayTV server reachable from Home Assistant.", "data": { - "base_url": "RelayTV base URL" + "base_url": "RelayTV server address", + "server_name": "Name" } } }, "error": { - "missing_base_url": "Please enter a RelayTV base URL." + "missing_base_url": "Please enter a RelayTV base URL.", + "missing_name": "Please enter a name.", + "already_configured": "This RelayTV server is already configured." } }, "options": { "step": { "init": { - "title": "Panel options", - "description": "Customize how the RelayTV panel appears in the Home Assistant sidebar.", + "title": "Sidebar panel", + "description": "Choose whether the sidebar is enabled and which server it displays.", "data": { - "panel_title": "Sidebar title", - "panel_icon": "Sidebar icon (MDI)", - "panel_path": "Sidebar path (URL slug)" + "panel_enabled": "Enable sidebar panel", + "panel_target_entry_id": "Server shown in sidebar" } } }