From f86d2fec4acc3d3acc77f13d31aa7f9f12de88a4 Mon Sep 17 00:00:00 2001 From: mcgeezy Date: Mon, 23 Feb 2026 21:16:21 -0600 Subject: [PATCH] expanded feature set --- custom_components/relaytv/__init__.py | 252 +++++++++++++++++++--- custom_components/relaytv/config_flow.py | 23 ++ custom_components/relaytv/const.py | 9 + custom_components/relaytv/media_player.py | 12 +- custom_components/relaytv/relaytv_api.py | 45 ++++ custom_components/relaytv/services.yaml | 123 +++++++++++ custom_components/relaytv/strings.json | 5 +- 7 files changed, 434 insertions(+), 35 deletions(-) diff --git a/custom_components/relaytv/__init__.py b/custom_components/relaytv/__init__.py index ab5e8f3..c27c51f 100644 --- a/custom_components/relaytv/__init__.py +++ b/custom_components/relaytv/__init__.py @@ -3,21 +3,26 @@ from __future__ import annotations import logging +import time from urllib.parse import urlparse from homeassistant.components import frontend from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ENTITY_ID -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.helpers import aiohttp_client, entity_registry as er +from homeassistant.helpers.event import async_track_state_change_event from homeassistant.helpers.storage import Store from .const import ( CONF_BASE_URL, CONF_PANEL_ENABLED, CONF_PANEL_TARGET_ENTRY_ID, + CONF_RESUME_POSITIONS, + CONF_SENSOR_STREAM_MAPPINGS, DATA_API, DATA_COORDINATOR, + DATA_LAST_SNAPSHOT_URL, DATA_PANEL_SETTINGS, DATA_STORE, DEFAULT_PANEL_ICON, @@ -26,17 +31,22 @@ from .const import ( DOMAIN, PLATFORMS, SERVICE_ANNOUNCE, + SERVICE_OVERLAY, SERVICE_PLAY_NOW, + SERVICE_PLAY_SYNCED, + SERVICE_PLAY_TEMPORARY, + SERVICE_PLAY_WITH_RESUME, SERVICE_SMART_URL, + SERVICE_SNAPSHOT, ) from .coordinator import RelayTVCoordinator from .relaytv_api import RelayTVApi _LOGGER = logging.getLogger(__name__) +RUNTIME_STORE_KEY = f"{DOMAIN}_runtime" 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 "" @@ -52,7 +62,6 @@ def _normalize_base_url(raw: str) -> str: async def async_setup(hass: HomeAssistant, config: dict) -> bool: - """Set up the RelayTV domain.""" hass.data.setdefault(DOMAIN, {}) return True @@ -74,6 +83,21 @@ async def _async_save_settings(hass: HomeAssistant) -> None: await data[DATA_STORE].async_save(data[DATA_PANEL_SETTINGS]) +async def _async_load_runtime_data(hass: HomeAssistant) -> dict: + data = hass.data.setdefault(DOMAIN, {}) + if "runtime_store" not in data: + data["runtime_store"] = Store(hass, 1, RUNTIME_STORE_KEY) + if "runtime_data" not in data: + data["runtime_data"] = await data["runtime_store"].async_load() or {CONF_RESUME_POSITIONS: {}} + data["runtime_data"].setdefault(CONF_RESUME_POSITIONS, {}) + return data["runtime_data"] + + +async def _async_save_runtime_data(hass: HomeAssistant) -> None: + data = hass.data.setdefault(DOMAIN, {}) + await data["runtime_store"].async_save(data["runtime_data"]) + + def _get_entry_data(hass: HomeAssistant, entry_id: str) -> dict | None: return hass.data.get(DOMAIN, {}).get(entry_id) @@ -117,6 +141,16 @@ def _resolve_entry_id_for_call(hass: HomeAssistant, call: ServiceCall) -> str | return _fallback_entry_id(hass) +def _resolve_entries_for_entities(hass: HomeAssistant, entity_ids: list[str]) -> list[str]: + registry = er.async_get(hass) + result: list[str] = [] + 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): + result.append(reg_entry.config_entry_id) + return list(dict.fromkeys(result)) + + def _register_panel(hass: HomeAssistant, *, path: str, title: str, icon: str, url: str) -> None: frontend.async_register_built_in_panel( hass, @@ -157,13 +191,7 @@ async def _async_update_panel(hass: HomeAssistant) -> None: return url = target[DATA_API].base_url - _register_panel( - hass, - path=DEFAULT_PANEL_PATH, - title=DEFAULT_PANEL_TITLE, - icon=DEFAULT_PANEL_ICON, - url=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) @@ -173,23 +201,100 @@ async def _async_set_default_sidebar_target(hass: HomeAssistant, entry: ConfigEn await _async_save_settings(hass) +def _entry_mappings(entry: ConfigEntry) -> list[dict]: + mappings = entry.options.get(CONF_SENSOR_STREAM_MAPPINGS, []) + if not isinstance(mappings, list): + return [] + valid: list[dict] = [] + for item in mappings: + if not isinstance(item, dict): + continue + sensor = item.get("sensor_entity_id") + url = item.get("url") + if isinstance(sensor, str) and isinstance(url, str) and sensor and url: + valid.append({"sensor_entity_id": sensor, "url": url}) + return valid + + +def _setup_mapping_listeners(hass: HomeAssistant, entry: ConfigEntry) -> list: + entry_data = _get_entry_data(hass, entry.entry_id) + if not entry_data: + return [] + + unsubscribers = [] + for mapping in _entry_mappings(entry): + sensor_entity_id = mapping["sensor_entity_id"] + url = mapping["url"] + + @callback + def _listener(event, sensor_entity_id=sensor_entity_id, url=url): + old_state = event.data.get("old_state") + new_state = event.data.get("new_state") + if new_state is None: + return + if old_state is not None and old_state.state == "on": + return + if new_state.state != "on": + return + hass.async_create_task(entry_data[DATA_API].play_temporary(url=url)) + + unsubscribers.append(async_track_state_change_event(hass, [sensor_entity_id], _listener)) + + return unsubscribers + + 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") return False await _async_ensure_settings(hass) + await _async_load_runtime_data(hass) session = aiohttp_client.async_get_clientsession(hass) api = RelayTVApi(session=session, base_url=base_url) coordinator = RelayTVCoordinator(hass=hass, api=api) - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = {DATA_API: api, DATA_COORDINATOR: coordinator} + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { + DATA_API: api, + DATA_COORDINATOR: coordinator, + DATA_LAST_SNAPSHOT_URL: None, + "mapping_unsubs": [], + } + + @callback + def _save_resume_position() -> None: + status = coordinator.data if isinstance(coordinator.data, dict) else {} + url = status.get("url") or (status.get("now_playing") or {}).get("url") + position = status.get("position") + duration = status.get("duration") + if not isinstance(url, str) or not url: + return + try: + pos = float(position) + except Exception: + return + try: + dur = float(duration) if duration is not None else 0.0 + except Exception: + dur = 0.0 + if pos < 60.0 or dur < 120.0: + return + + async def _save() -> None: + runtime = await _async_load_runtime_data(hass) + runtime[CONF_RESUME_POSITIONS][url] = pos + await _async_save_runtime_data(hass) + + hass.async_create_task(_save()) + + coordinator.async_add_listener(_save_resume_position) await coordinator.async_config_entry_first_refresh() await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + hass.data[DOMAIN][entry.entry_id]["mapping_unsubs"] = _setup_mapping_listeners(hass, entry) + await _async_set_default_sidebar_target(hass, entry) await _async_update_panel(hass) @@ -212,46 +317,127 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 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_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: ServiceCall): await _handle_play_now(call) - 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): - 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) + async def _handle_play_temporary(call: ServiceCall): + url = (call.data.get("url") or "").strip() + if not url: + return + 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_temporary( + url=url, + timeout_sec=call.data.get("timeout"), + volume_override=call.data.get("volume"), + ) + await store[DATA_COORDINATOR].async_request_refresh() + + async def _handle_overlay(call: ServiceCall): + 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].overlay( + text=call.data.get("text"), + duration=call.data.get("duration"), + position=call.data.get("position"), + image_url=call.data.get("image_url"), + ) + + async def _handle_play_synced(call: ServiceCall): + url = (call.data.get("url") or "").strip() + if not url: + return + delay = float(call.data.get("delay_buffer_sec") or 2) + start_at = time.time() + delay + targets = call.data.get("target_entities") or call.data.get(CONF_ENTITY_ID) or [] + if isinstance(targets, str): + targets = [targets] + entry_ids = _resolve_entries_for_entities(hass, [e for e in targets if isinstance(e, str)]) + if not entry_ids: + fallback = _resolve_entry_id_for_call(hass, call) + entry_ids = [fallback] if fallback else [] + for entry_id in entry_ids: + store = _get_entry_data(hass, entry_id) + if not store: + continue + await store[DATA_API].play_at(url=url, start_at=start_at) + await store[DATA_COORDINATOR].async_request_refresh() + + async def _handle_snapshot(call: ServiceCall): + 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 + data = await store[DATA_API].snapshot() or {} + snapshot_url = data.get("image_url") if isinstance(data, dict) else None + if snapshot_url: + store[DATA_LAST_SNAPSHOT_URL] = snapshot_url + await store[DATA_COORDINATOR].async_request_refresh() + + async def _handle_play_with_resume(call: ServiceCall): + url = (call.data.get("url") or "").strip() + if not url: + return + 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 + runtime = await _async_load_runtime_data(hass) + resume_position = runtime.get(CONF_RESUME_POSITIONS, {}).get(url) + await store[DATA_API].play(url=url, use_ytdlp=call.data.get("use_ytdlp"), cec=call.data.get("cec")) + if resume_position is not None: + await store[DATA_API].seek_abs(float(resume_position)) + await store[DATA_COORDINATOR].async_request_refresh() + + for service_name, handler in ( + (SERVICE_SMART_URL, _handle_smart_url), + (SERVICE_PLAY_NOW, _handle_play_now), + (SERVICE_ANNOUNCE, _handle_announce), + (SERVICE_PLAY_TEMPORARY, _handle_play_temporary), + (SERVICE_OVERLAY, _handle_overlay), + (SERVICE_PLAY_SYNCED, _handle_play_synced), + (SERVICE_SNAPSHOT, _handle_snapshot), + (SERVICE_PLAY_WITH_RESUME, _handle_play_with_resume), + ): + if not hass.services.has_service(DOMAIN, service_name): + hass.services.async_register(DOMAIN, service_name, handler) 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 entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - hass.data.get(DOMAIN, {}).pop(entry.entry_id, None) + entry_data = hass.data.get(DOMAIN, {}).pop(entry.entry_id, None) + if entry_data: + for unsub in entry_data.get("mapping_unsubs", []): + unsub() 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): + for service_name in ( + SERVICE_SMART_URL, + SERVICE_PLAY_NOW, + SERVICE_ANNOUNCE, + SERVICE_PLAY_TEMPORARY, + SERVICE_OVERLAY, + SERVICE_PLAY_SYNCED, + SERVICE_SNAPSHOT, + SERVICE_PLAY_WITH_RESUME, + ): if hass.services.has_service(DOMAIN, service_name): hass.services.async_remove(DOMAIN, service_name) return unload_ok @@ -261,9 +447,11 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Handle entry updates.""" base_url = _normalize_base_url(entry.data.get(CONF_BASE_URL, "")) store = _get_entry_data(hass, entry.entry_id) if store and base_url: store[DATA_API].base_url = base_url + for unsub in store.get("mapping_unsubs", []): + unsub() + store["mapping_unsubs"] = _setup_mapping_listeners(hass, entry) await _async_update_panel(hass) diff --git a/custom_components/relaytv/config_flow.py b/custom_components/relaytv/config_flow.py index b92b479..29a2233 100644 --- a/custom_components/relaytv/config_flow.py +++ b/custom_components/relaytv/config_flow.py @@ -9,12 +9,14 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.const import CONF_NAME from homeassistant.core import callback +from homeassistant.helpers import selector from homeassistant.helpers.storage import Store from .const import ( CONF_BASE_URL, CONF_PANEL_ENABLED, CONF_PANEL_TARGET_ENTRY_ID, + CONF_SENSOR_STREAM_MAPPINGS, CONF_SERVER_NAME, DATA_PANEL_SETTINGS, DATA_STORE, @@ -106,6 +108,23 @@ class RelayTVWebUIOptionsFlow(config_entries.OptionsFlow): await settings_store.async_save(updated) self.hass.data.setdefault(DOMAIN, {})[DATA_STORE] = settings_store self.hass.data[DOMAIN][DATA_PANEL_SETTINGS] = updated + + mappings = user_input.get(CONF_SENSOR_STREAM_MAPPINGS, []) + if not isinstance(mappings, list): + mappings = [] + clean_mappings = [] + for item in mappings: + if not isinstance(item, dict): + continue + sensor = item.get("sensor_entity_id") + url = item.get("url") + if isinstance(sensor, str) and isinstance(url, str) and sensor and url: + clean_mappings.append({"sensor_entity_id": sensor, "url": url}) + + self.hass.config_entries.async_update_entry( + self._config_entry, + options={CONF_SENSOR_STREAM_MAPPINGS: clean_mappings}, + ) await self.hass.config_entries.async_reload(self._config_entry.entry_id) return self.async_create_entry(title="", data={}) @@ -119,6 +138,10 @@ class RelayTVWebUIOptionsFlow(config_entries.OptionsFlow): CONF_PANEL_TARGET_ENTRY_ID, default=current_target, ): vol.In(choices), + vol.Optional( + CONF_SENSOR_STREAM_MAPPINGS, + default=self._config_entry.options.get(CONF_SENSOR_STREAM_MAPPINGS, []), + ): selector.ObjectSelector(), } ) diff --git a/custom_components/relaytv/const.py b/custom_components/relaytv/const.py index 37c0c39..b147ddb 100644 --- a/custom_components/relaytv/const.py +++ b/custom_components/relaytv/const.py @@ -20,9 +20,18 @@ DEFAULT_PANEL_PATH = "relaytv" SERVICE_SMART_URL = "smart_url" SERVICE_PLAY_NOW = "play_now" SERVICE_ANNOUNCE = "announce" +SERVICE_PLAY_TEMPORARY = "play_temporary" +SERVICE_OVERLAY = "overlay" +SERVICE_PLAY_SYNCED = "play_synced" +SERVICE_SNAPSHOT = "snapshot" +SERVICE_PLAY_WITH_RESUME = "play_with_resume" + +CONF_SENSOR_STREAM_MAPPINGS = "sensor_stream_mappings" +CONF_RESUME_POSITIONS = "resume_positions" # Data keys DATA_COORDINATOR = "coordinator" DATA_API = "api" DATA_STORE = "store" DATA_PANEL_SETTINGS = "panel_settings" +DATA_LAST_SNAPSHOT_URL = "last_snapshot_url" diff --git a/custom_components/relaytv/media_player.py b/custom_components/relaytv/media_player.py index 3e342fc..72c4911 100644 --- a/custom_components/relaytv/media_player.py +++ b/custom_components/relaytv/media_player.py @@ -18,7 +18,7 @@ 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 +from .const import DATA_API, DATA_COORDINATOR, DATA_LAST_SNAPSHOT_URL, DOMAIN def _num(v: Any) -> Optional[float]: @@ -222,6 +222,16 @@ class RelayTVMediaPlayer(CoordinatorEntity, MediaPlayerEntity): return t.replace(tzinfo=timezone.utc) return t + + @property + def extra_state_attributes(self) -> dict[str, Any]: + attrs: dict[str, Any] = {} + store = self.hass.data.get(DOMAIN, {}).get(self._entry.entry_id, {}) + snapshot_url = store.get(DATA_LAST_SNAPSHOT_URL) + if snapshot_url: + attrs["snapshot_url"] = snapshot_url + return attrs + @property def entity_picture(self) -> Optional[str]: v = _parse_status(self.coordinator.data) diff --git a/custom_components/relaytv/relaytv_api.py b/custom_components/relaytv/relaytv_api.py index 98bb3d6..a1338c3 100644 --- a/custom_components/relaytv/relaytv_api.py +++ b/custom_components/relaytv/relaytv_api.py @@ -86,6 +86,51 @@ class RelayTVApi: data = await self._request_json("POST", "enqueue", json={"url": url}) return data is not None + async def play_temporary( + self, + *, + url: str, + timeout_sec: float | None = None, + volume_override: float | None = None, + resume: bool = True, + resume_mode: str = "auto", + ) -> bool: + payload: dict[str, Any] = {"url": url, "resume": resume, "resume_mode": resume_mode} + if timeout_sec is not None: + payload["timeout_sec"] = float(timeout_sec) + if volume_override is not None: + payload["volume_override"] = float(volume_override) + return (await self._request_json("POST", "play_temporary", json=payload)) is not None + + async def overlay( + self, + *, + text: str | None = None, + duration: float | None = None, + position: str | None = None, + image_url: str | None = None, + ) -> bool: + payload: dict[str, Any] = {} + if text: + payload["text"] = text + if duration is not None: + payload["duration"] = float(duration) + if position: + payload["position"] = position + if image_url: + payload["image_url"] = image_url + return (await self._request_json("POST", "overlay", json=payload)) is not None + + async def play_at(self, *, url: str, start_at: float) -> bool: + payload = {"url": url, "start_at": float(start_at)} + return (await self._request_json("POST", "play_at", json=payload)) is not None + + async def snapshot(self) -> Optional[dict[str, Any]]: + data = await self._request_json("POST", "snapshot", json={}) + if data is not None: + return data + return await self._request_json("GET", "snapshot") + async def next(self) -> bool: """Skip to the next queued item (POST /next).""" data = await self._request_json("POST", "next", json={}) diff --git a/custom_components/relaytv/services.yaml b/custom_components/relaytv/services.yaml index 86ac187..d0dd355 100644 --- a/custom_components/relaytv/services.yaml +++ b/custom_components/relaytv/services.yaml @@ -65,3 +65,126 @@ announce: default: false selector: boolean: + +play_temporary: + name: Play temporary + description: Temporarily interrupt playback and resume afterward (RelayTV POST /play_temporary). + target: + entity: + domain: media_player + integration: relaytv + device: + integration: relaytv + fields: + url: + required: true + selector: + text: + timeout: + required: false + selector: + number: + min: 1 + max: 300 + mode: box + volume: + required: false + selector: + number: + min: 0 + max: 1 + step: 0.01 + mode: slider + +overlay: + name: Overlay + description: Display text/image overlay without interrupting playback (RelayTV POST /overlay). + target: + entity: + domain: media_player + integration: relaytv + device: + integration: relaytv + fields: + text: + required: false + selector: + text: + duration: + required: false + selector: + number: + min: 1 + max: 120 + mode: box + position: + required: false + selector: + select: + options: + - top-left + - top-right + - bottom-left + - bottom-right + - center + image_url: + required: false + selector: + text: + +play_synced: + name: Play synced + description: Start the same URL on multiple RelayTV entities at the same target timestamp. + fields: + url: + required: true + selector: + text: + target_entities: + required: true + selector: + entity: + multiple: true + domain: media_player + integration: relaytv + delay_buffer_sec: + required: false + default: 2 + selector: + number: + min: 1 + max: 20 + mode: box + +snapshot: + name: Snapshot + description: Capture a snapshot from RelayTV and expose snapshot_url on the entity. + target: + entity: + domain: media_player + integration: relaytv + device: + integration: relaytv + +play_with_resume: + name: Play with resume + description: Play URL and resume from previously stored position when available. + target: + entity: + domain: media_player + integration: relaytv + device: + integration: relaytv + fields: + url: + required: true + selector: + text: + use_ytdlp: + required: false + selector: + boolean: + cec: + required: false + selector: + boolean: diff --git a/custom_components/relaytv/strings.json b/custom_components/relaytv/strings.json index cbb5932..aca9e4e 100644 --- a/custom_components/relaytv/strings.json +++ b/custom_components/relaytv/strings.json @@ -21,10 +21,11 @@ "step": { "init": { "title": "Sidebar panel", - "description": "Choose whether the sidebar is enabled and which server it displays.", + "description": "Choose sidebar behavior and optional sensor-to-stream mappings.", "data": { "panel_enabled": "Enable sidebar panel", - "panel_target_entry_id": "Server shown in sidebar" + "panel_target_entry_id": "Server shown in sidebar", + "sensor_stream_mappings": "Sensor to Stream Mapping (list of {sensor_entity_id, url})" } } }