expanded feature set
This commit is contained in:
@@ -3,21 +3,26 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import time
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from homeassistant.components import frontend
|
from homeassistant.components import frontend
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.const import CONF_ENTITY_ID
|
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 import aiohttp_client, entity_registry as er
|
||||||
|
from homeassistant.helpers.event import async_track_state_change_event
|
||||||
from homeassistant.helpers.storage import Store
|
from homeassistant.helpers.storage import Store
|
||||||
|
|
||||||
from .const import (
|
from .const import (
|
||||||
CONF_BASE_URL,
|
CONF_BASE_URL,
|
||||||
CONF_PANEL_ENABLED,
|
CONF_PANEL_ENABLED,
|
||||||
CONF_PANEL_TARGET_ENTRY_ID,
|
CONF_PANEL_TARGET_ENTRY_ID,
|
||||||
|
CONF_RESUME_POSITIONS,
|
||||||
|
CONF_SENSOR_STREAM_MAPPINGS,
|
||||||
DATA_API,
|
DATA_API,
|
||||||
DATA_COORDINATOR,
|
DATA_COORDINATOR,
|
||||||
|
DATA_LAST_SNAPSHOT_URL,
|
||||||
DATA_PANEL_SETTINGS,
|
DATA_PANEL_SETTINGS,
|
||||||
DATA_STORE,
|
DATA_STORE,
|
||||||
DEFAULT_PANEL_ICON,
|
DEFAULT_PANEL_ICON,
|
||||||
@@ -26,17 +31,22 @@ from .const import (
|
|||||||
DOMAIN,
|
DOMAIN,
|
||||||
PLATFORMS,
|
PLATFORMS,
|
||||||
SERVICE_ANNOUNCE,
|
SERVICE_ANNOUNCE,
|
||||||
|
SERVICE_OVERLAY,
|
||||||
SERVICE_PLAY_NOW,
|
SERVICE_PLAY_NOW,
|
||||||
|
SERVICE_PLAY_SYNCED,
|
||||||
|
SERVICE_PLAY_TEMPORARY,
|
||||||
|
SERVICE_PLAY_WITH_RESUME,
|
||||||
SERVICE_SMART_URL,
|
SERVICE_SMART_URL,
|
||||||
|
SERVICE_SNAPSHOT,
|
||||||
)
|
)
|
||||||
from .coordinator import RelayTVCoordinator
|
from .coordinator import RelayTVCoordinator
|
||||||
from .relaytv_api import RelayTVApi
|
from .relaytv_api import RelayTVApi
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
RUNTIME_STORE_KEY = f"{DOMAIN}_runtime"
|
||||||
|
|
||||||
|
|
||||||
def _normalize_base_url(raw: str) -> str:
|
def _normalize_base_url(raw: str) -> str:
|
||||||
"""Normalize user input into a URL safe for iframe embedding."""
|
|
||||||
raw = (raw or "").strip()
|
raw = (raw or "").strip()
|
||||||
if not raw:
|
if not raw:
|
||||||
return ""
|
return ""
|
||||||
@@ -52,7 +62,6 @@ def _normalize_base_url(raw: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
async def async_setup(hass: HomeAssistant, config: dict) -> bool:
|
async def async_setup(hass: HomeAssistant, config: dict) -> bool:
|
||||||
"""Set up the RelayTV domain."""
|
|
||||||
hass.data.setdefault(DOMAIN, {})
|
hass.data.setdefault(DOMAIN, {})
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -74,6 +83,21 @@ async def _async_save_settings(hass: HomeAssistant) -> None:
|
|||||||
await data[DATA_STORE].async_save(data[DATA_PANEL_SETTINGS])
|
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:
|
def _get_entry_data(hass: HomeAssistant, entry_id: str) -> dict | None:
|
||||||
return hass.data.get(DOMAIN, {}).get(entry_id)
|
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)
|
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:
|
def _register_panel(hass: HomeAssistant, *, path: str, title: str, icon: str, url: str) -> None:
|
||||||
frontend.async_register_built_in_panel(
|
frontend.async_register_built_in_panel(
|
||||||
hass,
|
hass,
|
||||||
@@ -157,13 +191,7 @@ async def _async_update_panel(hass: HomeAssistant) -> None:
|
|||||||
return
|
return
|
||||||
|
|
||||||
url = target[DATA_API].base_url
|
url = target[DATA_API].base_url
|
||||||
_register_panel(
|
_register_panel(hass, path=DEFAULT_PANEL_PATH, title=DEFAULT_PANEL_TITLE, icon=DEFAULT_PANEL_ICON, url=url)
|
||||||
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)
|
_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)
|
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:
|
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, ""))
|
base_url = _normalize_base_url(entry.data.get(CONF_BASE_URL, ""))
|
||||||
if not base_url:
|
if not base_url:
|
||||||
_LOGGER.error("RelayTV base URL is empty")
|
_LOGGER.error("RelayTV base URL is empty")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
await _async_ensure_settings(hass)
|
await _async_ensure_settings(hass)
|
||||||
|
await _async_load_runtime_data(hass)
|
||||||
|
|
||||||
session = aiohttp_client.async_get_clientsession(hass)
|
session = aiohttp_client.async_get_clientsession(hass)
|
||||||
api = RelayTVApi(session=session, base_url=base_url)
|
api = RelayTVApi(session=session, base_url=base_url)
|
||||||
coordinator = RelayTVCoordinator(hass=hass, api=api)
|
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 coordinator.async_config_entry_first_refresh()
|
||||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
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_set_default_sidebar_target(hass, entry)
|
||||||
await _async_update_panel(hass)
|
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
|
store = _get_entry_data(hass, entry_id) if entry_id else None
|
||||||
if not store:
|
if not store:
|
||||||
return
|
return
|
||||||
await store[DATA_API].play(
|
await store[DATA_API].play(url=url, use_ytdlp=call.data.get("use_ytdlp"), cec=call.data.get("cec"))
|
||||||
url=url,
|
|
||||||
use_ytdlp=call.data.get("use_ytdlp"),
|
|
||||||
cec=call.data.get("cec"),
|
|
||||||
)
|
|
||||||
await store[DATA_COORDINATOR].async_request_refresh()
|
await store[DATA_COORDINATOR].async_request_refresh()
|
||||||
|
|
||||||
async def _handle_announce(call: ServiceCall):
|
async def _handle_announce(call: ServiceCall):
|
||||||
await _handle_play_now(call)
|
await _handle_play_now(call)
|
||||||
|
|
||||||
if not hass.services.has_service(DOMAIN, SERVICE_SMART_URL):
|
async def _handle_play_temporary(call: ServiceCall):
|
||||||
hass.services.async_register(DOMAIN, SERVICE_SMART_URL, _handle_smart_url)
|
url = (call.data.get("url") or "").strip()
|
||||||
if not hass.services.has_service(DOMAIN, SERVICE_PLAY_NOW):
|
if not url:
|
||||||
hass.services.async_register(DOMAIN, SERVICE_PLAY_NOW, _handle_play_now)
|
return
|
||||||
if not hass.services.has_service(DOMAIN, SERVICE_ANNOUNCE):
|
entry_id = _resolve_entry_id_for_call(hass, call)
|
||||||
hass.services.async_register(DOMAIN, SERVICE_ANNOUNCE, _handle_announce)
|
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))
|
entry.async_on_unload(entry.add_update_listener(_async_update_listener))
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
"""Unload RelayTV entry."""
|
|
||||||
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
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)
|
settings = await _async_ensure_settings(hass)
|
||||||
if settings.get(CONF_PANEL_TARGET_ENTRY_ID) == entry.entry_id:
|
if settings.get(CONF_PANEL_TARGET_ENTRY_ID) == entry.entry_id:
|
||||||
replacement_id = _fallback_entry_id(hass)
|
replacement_id = _fallback_entry_id(hass)
|
||||||
settings[CONF_PANEL_TARGET_ENTRY_ID] = replacement_id
|
settings[CONF_PANEL_TARGET_ENTRY_ID] = replacement_id
|
||||||
await _async_save_settings(hass)
|
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)):
|
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)
|
_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):
|
if hass.services.has_service(DOMAIN, service_name):
|
||||||
hass.services.async_remove(DOMAIN, service_name)
|
hass.services.async_remove(DOMAIN, service_name)
|
||||||
return unload_ok
|
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:
|
async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||||
"""Handle entry updates."""
|
|
||||||
base_url = _normalize_base_url(entry.data.get(CONF_BASE_URL, ""))
|
base_url = _normalize_base_url(entry.data.get(CONF_BASE_URL, ""))
|
||||||
store = _get_entry_data(hass, entry.entry_id)
|
store = _get_entry_data(hass, entry.entry_id)
|
||||||
if store and base_url:
|
if store and base_url:
|
||||||
store[DATA_API].base_url = 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)
|
await _async_update_panel(hass)
|
||||||
|
|||||||
@@ -9,12 +9,14 @@ import voluptuous as vol
|
|||||||
from homeassistant import config_entries
|
from homeassistant import config_entries
|
||||||
from homeassistant.const import CONF_NAME
|
from homeassistant.const import CONF_NAME
|
||||||
from homeassistant.core import callback
|
from homeassistant.core import callback
|
||||||
|
from homeassistant.helpers import selector
|
||||||
from homeassistant.helpers.storage import Store
|
from homeassistant.helpers.storage import Store
|
||||||
|
|
||||||
from .const import (
|
from .const import (
|
||||||
CONF_BASE_URL,
|
CONF_BASE_URL,
|
||||||
CONF_PANEL_ENABLED,
|
CONF_PANEL_ENABLED,
|
||||||
CONF_PANEL_TARGET_ENTRY_ID,
|
CONF_PANEL_TARGET_ENTRY_ID,
|
||||||
|
CONF_SENSOR_STREAM_MAPPINGS,
|
||||||
CONF_SERVER_NAME,
|
CONF_SERVER_NAME,
|
||||||
DATA_PANEL_SETTINGS,
|
DATA_PANEL_SETTINGS,
|
||||||
DATA_STORE,
|
DATA_STORE,
|
||||||
@@ -106,6 +108,23 @@ class RelayTVWebUIOptionsFlow(config_entries.OptionsFlow):
|
|||||||
await settings_store.async_save(updated)
|
await settings_store.async_save(updated)
|
||||||
self.hass.data.setdefault(DOMAIN, {})[DATA_STORE] = settings_store
|
self.hass.data.setdefault(DOMAIN, {})[DATA_STORE] = settings_store
|
||||||
self.hass.data[DOMAIN][DATA_PANEL_SETTINGS] = updated
|
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)
|
await self.hass.config_entries.async_reload(self._config_entry.entry_id)
|
||||||
return self.async_create_entry(title="", data={})
|
return self.async_create_entry(title="", data={})
|
||||||
|
|
||||||
@@ -119,6 +138,10 @@ class RelayTVWebUIOptionsFlow(config_entries.OptionsFlow):
|
|||||||
CONF_PANEL_TARGET_ENTRY_ID,
|
CONF_PANEL_TARGET_ENTRY_ID,
|
||||||
default=current_target,
|
default=current_target,
|
||||||
): vol.In(choices),
|
): vol.In(choices),
|
||||||
|
vol.Optional(
|
||||||
|
CONF_SENSOR_STREAM_MAPPINGS,
|
||||||
|
default=self._config_entry.options.get(CONF_SENSOR_STREAM_MAPPINGS, []),
|
||||||
|
): selector.ObjectSelector(),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -20,9 +20,18 @@ DEFAULT_PANEL_PATH = "relaytv"
|
|||||||
SERVICE_SMART_URL = "smart_url"
|
SERVICE_SMART_URL = "smart_url"
|
||||||
SERVICE_PLAY_NOW = "play_now"
|
SERVICE_PLAY_NOW = "play_now"
|
||||||
SERVICE_ANNOUNCE = "announce"
|
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 keys
|
||||||
DATA_COORDINATOR = "coordinator"
|
DATA_COORDINATOR = "coordinator"
|
||||||
DATA_API = "api"
|
DATA_API = "api"
|
||||||
DATA_STORE = "store"
|
DATA_STORE = "store"
|
||||||
DATA_PANEL_SETTINGS = "panel_settings"
|
DATA_PANEL_SETTINGS = "panel_settings"
|
||||||
|
DATA_LAST_SNAPSHOT_URL = "last_snapshot_url"
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ from homeassistant.core import HomeAssistant
|
|||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
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]:
|
def _num(v: Any) -> Optional[float]:
|
||||||
@@ -222,6 +222,16 @@ class RelayTVMediaPlayer(CoordinatorEntity, MediaPlayerEntity):
|
|||||||
return t.replace(tzinfo=timezone.utc)
|
return t.replace(tzinfo=timezone.utc)
|
||||||
return t
|
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
|
@property
|
||||||
def entity_picture(self) -> Optional[str]:
|
def entity_picture(self) -> Optional[str]:
|
||||||
v = _parse_status(self.coordinator.data)
|
v = _parse_status(self.coordinator.data)
|
||||||
|
|||||||
@@ -86,6 +86,51 @@ class RelayTVApi:
|
|||||||
data = await self._request_json("POST", "enqueue", json={"url": url})
|
data = await self._request_json("POST", "enqueue", json={"url": url})
|
||||||
return data is not None
|
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:
|
async def next(self) -> bool:
|
||||||
"""Skip to the next queued item (POST /next)."""
|
"""Skip to the next queued item (POST /next)."""
|
||||||
data = await self._request_json("POST", "next", json={})
|
data = await self._request_json("POST", "next", json={})
|
||||||
|
|||||||
@@ -65,3 +65,126 @@ announce:
|
|||||||
default: false
|
default: false
|
||||||
selector:
|
selector:
|
||||||
boolean:
|
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:
|
||||||
|
|||||||
@@ -21,10 +21,11 @@
|
|||||||
"step": {
|
"step": {
|
||||||
"init": {
|
"init": {
|
||||||
"title": "Sidebar panel",
|
"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": {
|
"data": {
|
||||||
"panel_enabled": "Enable sidebar panel",
|
"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})"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user