3 Commits

Author SHA1 Message Date
alexei.dolgolyov 97c1784ad4 feat(client): v0.3.0 server compat — WS subprotocol auth, 429 retry, HTTPS, X-Request-ID
Aligns the integration with the four wire-level changes shipped in
media-server v0.3.0/0.3.1 without breaking back-compat with older
server versions or pre-existing config entries.

- WebSocket auth via Sec-WebSocket-Protocol: media-server.token.<T>
  (preferred by server v0.3.0+). The ?token= query is still sent so
  older servers and unauthenticated mode both keep working — aiohttp
  completes the handshake even when the server doesn't echo the
  subprotocol back.
- 429 Too Many Requests surfaced as MediaServerRateLimitError with
  Retry-After parsed; execute_script() sleeps min(retry_after, 30)
  and retries once before falling through to the caller.
- Optional HTTPS/WSS (CONF_USE_SSL) + optional certificate verification
  toggle (CONF_VERIFY_SSL) wired through the config flow, client, and
  WebSocket. Defaults preserve http+verified behaviour, so existing
  config entries are unchanged.
- X-Request-ID header (uuid4 hex) on every HTTP call so HA-side issues
  can be cross-referenced with the server's access/audit logs. The
  format matches the server's ^[A-Za-z0-9._-]{1,128}\$ allow-list so
  the id is preserved verbatim instead of being replaced server-side.

Bumps manifest version to 0.3.3.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 11:37:59 +03:00
alexei.dolgolyov 8e8acccbb2 chore: release v0.3.2
Release / release (push) Successful in 3s
2026-05-18 13:14:03 +03:00
alexei.dolgolyov b92b69b0e8 chore: release v0.3.1
Release / release (push) Successful in 4s
2026-05-18 03:18:09 +03:00
12 changed files with 360 additions and 67 deletions
+13 -24
View File
@@ -1,30 +1,19 @@
## v0.3.0 (2026-05-15)
### Migration / Behavior Changes
- Each physical monitor is now its own Home Assistant device, linked to the media-server hub via `via_device`. Existing brightness and power entities migrate to per-display devices automatically on first reload — `unique_id`s are preserved, but entities will move under new devices in the UI and can be placed in their own area/room ([4156ded](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/4156ded))
- The hub keeps the `media_player` and script buttons; per-display devices hold the power switch, brightness slider, and the new DDC/CI capability entities ([4156ded](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/4156ded))
## v0.3.2 (2026-05-18)
### Features
- **New diagnostic sensors**: `DisplayResolutionSensor` exposes the active resolution parsed from EDID ([4156ded](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/4156ded))
- **New diagnostic binary sensors**: `DisplayPrimaryBinarySensor` and `DisplayPowerControlBinarySensor` make it visible why a power switch is or isn't created for a given display ([4156ded](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/4156ded))
- **New select entities**:
- `DisplayInputSourceSelect` — switch active input (HDMI1, DP1, etc.) via DDC/CI
- `DisplayColorPresetSelect` — color temperature presets
- `DisplayPictureModeSelect` — VCP 0xDC scene modes ([4156ded](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/4156ded))
- **New number entity**: `DisplayContrastNumber` exposed alongside the existing brightness slider ([4156ded](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/4156ded))
- Per-display devices now show real manufacturer/model pulled from EDID; device names no longer prepend the hub title (the hierarchy is already shown via `via_device`) ([4156ded](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/4156ded))
- Select and number entities verify the server's `success` flag and re-sync from the actual monitor state when a write is silently rejected — some monitors honor DDC/CI reads but ignore writes for certain codes ([4156ded](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/4156ded))
- **Targeted service calls** `remote_media_player.execute_script` and `remote_media_player.play_media_file` now accept Home Assistant's standard `target:` block (`device_id`, `entity_id`, `area_id`). Calls without a target keep the legacy fan-out behavior and run on every configured hub, so existing automations continue to work. Targets are resolved against the device/entity registries and filtered to Remote Media Player hubs only; unmatched targets log a warning and are skipped.
- `services.yaml` declares `target:` with `integration: remote_media_player`, and the voluptuous schemas accept the `device_id` / `entity_id` / `area_id` keys HA injects.
- **`execute_script` parameter rename** — the script payload field is now `params:` (a named dict, validated against the server-side script schema) instead of the previous `args:` list. **Breaking** for automations that still use `args:`; update them to `params:` with the named keys your script expects.
### Performance
- `api_client` no longer forces `?refresh=true` on every poll, letting the integration ride the media server's TTL cache instead of triggering a full DDC/CI probe per entity update ([4156ded](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/4156ded))
### Bug Fixes
- **Compatibility with new browser-folders response shape** — `MediaServerClient.list_browser_folders()` now unwraps the new server response (`{"folders": {...}, "management_enabled": bool}`) introduced after server commit `c9ee41a`, while still accepting the older flat dict. Restores folder listing on freshly updated media servers.
### UI / Localization
- Updated English and Russian translations + `strings.json` for the new `target:`-aware service descriptions and the `params` field rename. Service descriptions now explain the "no target = all hubs" behavior.
### Documentation
- README "Execute Script Service" section rewritten to document the `target:` block, the `params:` payload, and the destructive-script safety note.
---
<details>
<summary>All Commits</summary>
| Hash | Message | Author |
|------|---------|--------|
| [4156ded](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/4156ded) | feat(displays): per-display devices + DDC/CI capability entities | alexei.dolgolyov |
</details>
All changes above are bundled in the single release commit tagged [v0.3.2](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/src/tag/v0.3.2).
@@ -207,17 +207,28 @@ automation:
### Execute Script Service
You can also execute scripts with arguments using the service:
Run a pre-defined server script. Use the `target:` block to scope the call to a
specific hub (or area / entity); omit it to fan out to **all** configured hubs.
```yaml
# Run on a single hub
service: remote_media_player.execute_script
target:
device_id: <device id of the hub>
data:
script_name: "echo_test"
args:
- "arg1"
- "arg2"
params:
message: "hello"
# Run on all hubs (legacy fan-out)
service: remote_media_player.execute_script
data:
script_name: "shutdown"
```
> Without a target, the service runs on every configured Remote Media Player.
> For destructive scripts (shutdown, reboot, lock) always pin a target.
## Lovelace Card Examples
### Basic Media Control Card
+108 -10
View File
@@ -8,9 +8,11 @@ from typing import Any
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.const import ATTR_AREA_ID, ATTR_DEVICE_ID, ATTR_ENTITY_ID, Platform
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers import entity_registry as er
from .api_client import MediaServerClient, MediaServerError
from .const import (
@@ -20,6 +22,10 @@ from .const import (
CONF_HOST,
CONF_PORT,
CONF_TOKEN,
CONF_USE_SSL,
CONF_VERIFY_SSL,
DEFAULT_USE_SSL,
DEFAULT_VERIFY_SSL,
DOMAIN,
SERVICE_EXECUTE_SCRIPT,
SERVICE_PLAY_MEDIA_FILE,
@@ -39,11 +45,20 @@ PLATFORMS: list[Platform] = [
Platform.SELECT,
]
# Target-selector fields injected by HA when `target:` is declared in services.yaml.
# Listed explicitly so the voluptuous schema does not strip them.
_TARGET_FIELDS = {
vol.Optional(ATTR_DEVICE_ID): vol.Any(cv.string, [cv.string]),
vol.Optional(ATTR_ENTITY_ID): vol.Any(cv.string, [cv.string]),
vol.Optional(ATTR_AREA_ID): vol.Any(cv.string, [cv.string]),
}
# Service schema for execute_script
SERVICE_EXECUTE_SCRIPT_SCHEMA = vol.Schema(
{
vol.Required(ATTR_SCRIPT_NAME): cv.string,
vol.Optional(ATTR_SCRIPT_PARAMS, default={}): dict,
**_TARGET_FIELDS,
}
)
@@ -51,10 +66,76 @@ SERVICE_EXECUTE_SCRIPT_SCHEMA = vol.Schema(
SERVICE_PLAY_MEDIA_FILE_SCHEMA = vol.Schema(
{
vol.Required(ATTR_FILE_PATH): cv.string,
**_TARGET_FIELDS,
}
)
def _as_list(value: Any) -> list[str]:
"""Normalize a target field to a list of IDs (HA passes str or list)."""
if value is None:
return []
if isinstance(value, str):
return [value]
return list(value)
def _resolve_entry_ids(hass: HomeAssistant, call: ServiceCall) -> list[str]:
"""Resolve target selectors in a ServiceCall to config entry IDs.
Returns the entry IDs of Remote Media Player hubs that match the target.
If no target is provided, returns all configured entries (legacy fan-out).
Targets that don't resolve to any of our entries are skipped with a warning.
"""
device_ids = set(_as_list(call.data.get(ATTR_DEVICE_ID)))
entity_ids = set(_as_list(call.data.get(ATTR_ENTITY_ID)))
area_ids = set(_as_list(call.data.get(ATTR_AREA_ID)))
domain_entries: set[str] = set(hass.data.get(DOMAIN, {}).keys())
if not (device_ids or entity_ids or area_ids):
return list(domain_entries)
dev_reg = dr.async_get(hass)
ent_reg = er.async_get(hass)
# Expand area_id -> device_ids (devices located in that area).
if area_ids:
for device in dev_reg.devices.values():
if device.area_id in area_ids:
device_ids.add(device.id)
matched: set[str] = set()
for device_id in device_ids:
device = dev_reg.async_get(device_id)
if device is None:
continue
for entry_id in device.config_entries:
if entry_id in domain_entries:
matched.add(entry_id)
for entity_id in entity_ids:
entity = ent_reg.async_get(entity_id)
if entity is None or entity.config_entry_id is None:
continue
if entity.config_entry_id in domain_entries:
matched.add(entity.config_entry_id)
if not matched:
_LOGGER.warning(
"Service call targeted device(s)/entity(ies)/area(s) %s but no "
"Remote Media Player hubs matched — nothing will be executed",
{
"device_id": sorted(device_ids),
"entity_id": sorted(entity_ids),
"area_id": sorted(area_ids),
},
)
return list(matched)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Remote Media Player from a config entry.
@@ -67,11 +148,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""
_LOGGER.debug("Setting up Remote Media Player: %s", entry.entry_id)
# Create API client
# Create API client. ``use_ssl`` / ``verify_ssl`` were added in v0.3.3;
# ``.get(..., default)`` keeps pre-existing config entries (which lack the
# keys entirely) working at the old http+verify defaults.
client = MediaServerClient(
host=entry.data[CONF_HOST],
port=entry.data[CONF_PORT],
token=entry.data[CONF_TOKEN],
use_ssl=entry.data.get(CONF_USE_SSL, DEFAULT_USE_SSL),
verify_ssl=entry.data.get(CONF_VERIFY_SSL, DEFAULT_VERIFY_SSL),
)
# Verify connection
@@ -108,17 +193,23 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
# Register services if not already registered
if not hass.services.has_service(DOMAIN, SERVICE_EXECUTE_SCRIPT):
async def async_execute_script(call: ServiceCall) -> dict[str, Any]:
"""Execute a script on the media server."""
"""Execute a script on the targeted media server hubs."""
script_name = call.data[ATTR_SCRIPT_NAME]
script_params = call.data.get(ATTR_SCRIPT_PARAMS, {})
target_entries = _resolve_entry_ids(hass, call)
_LOGGER.debug(
"Executing script '%s' with params: %s", script_name, script_params
"Executing script '%s' with params %s on entries: %s",
script_name,
script_params,
target_entries,
)
# Get all clients and execute on all of them
results = {}
for entry_id, data in hass.data[DOMAIN].items():
results: dict[str, Any] = {}
for entry_id in target_entries:
data = hass.data[DOMAIN].get(entry_id)
if data is None:
continue
client: MediaServerClient = data["client"]
try:
result = await client.execute_script(script_name, script_params)
@@ -152,10 +243,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_play_media_file(call: ServiceCall) -> None:
"""Handle play_media_file service call."""
file_path = call.data[ATTR_FILE_PATH]
_LOGGER.debug("Service play_media_file called with path: %s", file_path)
target_entries = _resolve_entry_ids(hass, call)
_LOGGER.debug(
"Service play_media_file called with path '%s' on entries: %s",
file_path,
target_entries,
)
# Execute on all configured media server instances
for entry_id, data in hass.data[DOMAIN].items():
for entry_id in target_entries:
data = hass.data[DOMAIN].get(entry_id)
if data is None:
continue
client: MediaServerClient = data["client"]
try:
await client.play_media_file(file_path)
@@ -5,6 +5,7 @@ from __future__ import annotations
import asyncio
import hashlib
import logging
import uuid
from collections.abc import Callable
from typing import Any
@@ -55,6 +56,18 @@ class MediaServerAuthError(MediaServerError):
"""Exception for authentication errors."""
class MediaServerRateLimitError(MediaServerError):
"""Raised when the server replies with HTTP 429.
The media server's in-process token-bucket limiter (v0.3.0+) returns 429
with a ``Retry-After`` header — capture it so callers can back off.
"""
def __init__(self, message: str, retry_after: float | None = None) -> None:
super().__init__(message)
self.retry_after = retry_after
class MediaServerClient:
"""Client for the Media Server REST API."""
@@ -64,6 +77,8 @@ class MediaServerClient:
port: int,
token: str,
session: aiohttp.ClientSession | None = None,
use_ssl: bool = False,
verify_ssl: bool = True,
) -> None:
"""Initialize the client.
@@ -72,13 +87,22 @@ class MediaServerClient:
port: Server port
token: API authentication token
session: Optional aiohttp session (will create one if not provided)
use_ssl: If True, talk HTTPS instead of HTTP. The media server v0.3.0+
supports ``ssl_certfile`` / ``ssl_keyfile`` in ``config.yaml``.
verify_ssl: If False, skip TLS certificate verification (only needed
for self-signed certs on a trusted LAN).
"""
self._host = host
self._port = int(port) # Ensure port is an integer
self._token = token
self._session = session
self._own_session = session is None
self._base_url = f"http://{host}:{self._port}"
self._use_ssl = use_ssl
# aiohttp accepts ``ssl=False`` to disable verification; ``None`` keeps
# the default verifying SSLContext.
self._ssl: bool | None = False if (use_ssl and not verify_ssl) else None
scheme = "https" if use_ssl else "http"
self._base_url = f"{scheme}://{host}:{self._port}"
async def _ensure_session(self) -> aiohttp.ClientSession:
"""Ensure we have an aiohttp session."""
@@ -98,8 +122,18 @@ class MediaServerClient:
When no token is configured the media server runs in anonymous mode
(``auth.auth_enabled()`` returns False), so we omit the Authorization
header entirely rather than sending ``Bearer `` with an empty value.
Every request carries a per-call ``X-Request-ID`` that the media server
echoes back into its log lines (audit log + access log) so a problem
in HA can be correlated to the matching server-side entry. The id is
a UUID4 hex (32 chars) which fits the server's ``[A-Za-z0-9._-]{1,128}``
allow-list and is therefore preserved verbatim instead of being
replaced by a fresh server-side id.
"""
headers = {"Content-Type": "application/json"}
headers = {
"Content-Type": "application/json",
"X-Request-ID": uuid.uuid4().hex,
}
if self._token:
headers["Authorization"] = f"Bearer {self._token}"
return headers
@@ -129,17 +163,38 @@ class MediaServerClient:
"""
session = await self._ensure_session()
url = f"{self._base_url}{endpoint}"
headers = self._get_headers() if auth_required else {}
# Always send X-Request-ID, even on unauthenticated calls — it's the
# observability hook, not an auth token, and the health endpoint
# benefits from being log-correlated just like every other.
headers = self._get_headers() if auth_required else {
"Content-Type": "application/json",
"X-Request-ID": uuid.uuid4().hex,
}
try:
timeout = aiohttp.ClientTimeout(total=10)
async with session.request(
method, url, headers=headers, json=json_data, timeout=timeout
method,
url,
headers=headers,
json=json_data,
timeout=timeout,
ssl=self._ssl,
) as response:
if response.status == 401:
raise MediaServerAuthError("Invalid API token")
if response.status == 403:
raise MediaServerAuthError("Access forbidden")
if response.status == 429:
retry_after_raw = response.headers.get("Retry-After", "")
try:
retry_after = float(retry_after_raw) if retry_after_raw else None
except ValueError:
retry_after = None
raise MediaServerRateLimitError(
f"Rate limited by server (retry after {retry_after}s)",
retry_after=retry_after,
)
response.raise_for_status()
return await response.json()
@@ -307,6 +362,11 @@ class MediaServerClient:
) -> dict[str, Any]:
"""Execute a script on the server.
The server (v0.3.0+) rate-limits ``/api/scripts/execute`` at 10/min per
peer. If we hit 429 we wait for ``Retry-After`` (capped at 30 s) and
retry once — enough for a brief HA-side burst without masking a real
sustained overload, which falls through as ``MediaServerRateLimitError``.
Args:
script_name: Name of the script to execute
params: Optional named parameters (validated against script schema)
@@ -316,7 +376,17 @@ class MediaServerClient:
"""
endpoint = f"{API_SCRIPTS_EXECUTE}/{script_name}"
json_data = {"params": params or {}}
return await self._request("POST", endpoint, json_data)
try:
return await self._request("POST", endpoint, json_data)
except MediaServerRateLimitError as err:
wait = min(err.retry_after or 5.0, 30.0)
_LOGGER.warning(
"execute_script(%s) rate-limited, retrying after %.1fs",
script_name,
wait,
)
await asyncio.sleep(wait)
return await self._request("POST", endpoint, json_data)
async def get_media_folders(self) -> dict[str, dict[str, Any]]:
"""Get configured media folders.
@@ -324,7 +394,12 @@ class MediaServerClient:
Returns:
Dictionary of folders with folder_id as key and folder config as value
"""
return await self._request("GET", API_BROWSER_FOLDERS)
response = await self._request("GET", API_BROWSER_FOLDERS)
# Server >= c9ee41a wraps the result as {"folders": {...}, "management_enabled": bool}.
# Older servers returned the flat folder dict directly.
if isinstance(response, dict) and "folders" in response and isinstance(response["folders"], dict):
return response["folders"]
return response
async def browse_folder(
self, folder_id: str, path: str = "", offset: int = 0, limit: int = 100
@@ -443,6 +518,8 @@ class MediaServerWebSocket:
on_disconnect: Callable[[], None] | None = None,
on_scripts_changed: Callable[[], None] | None = None,
on_foreground_update: Callable[[dict[str, Any]], None] | None = None,
use_ssl: bool = False,
verify_ssl: bool = True,
) -> None:
"""Initialize the WebSocket client.
@@ -454,6 +531,8 @@ class MediaServerWebSocket:
on_disconnect: Callback when connection lost
on_scripts_changed: Callback when scripts have changed
on_foreground_update: Callback when foreground process changes
use_ssl: If True, talk WSS instead of WS.
verify_ssl: If False, skip TLS certificate verification.
"""
self._host = host
self._port = int(port)
@@ -462,11 +541,18 @@ class MediaServerWebSocket:
self._on_disconnect = on_disconnect
self._on_scripts_changed = on_scripts_changed
self._on_foreground_update = on_foreground_update
self._use_ssl = use_ssl
self._ssl: bool | None = False if (use_ssl and not verify_ssl) else None
# The server's WS endpoint accepts an unauthenticated connection when
# api_tokens is empty (see media.py:websocket_endpoint), so we only
# append ?token=... when one was configured.
# append ?token=... when one was configured. Pre-0.3.0 servers only
# know the query path; 0.3.0+ servers prefer the ``Sec-WebSocket-Protocol``
# subprotocol (which keeps the token out of URLs / Referer / logs) but
# still accept the query as a documented back-compat fallback. We send
# both so the integration works against either server version.
token_query = f"?token={token}" if token else ""
self._ws_url = f"ws://{host}:{self._port}/api/media/ws{token_query}"
scheme = "wss" if use_ssl else "ws"
self._ws_url = f"{scheme}://{host}:{self._port}/api/media/ws{token_query}"
self._session: aiohttp.ClientSession | None = None
self._ws: aiohttp.ClientWebSocketResponse | None = None
self._receive_task: asyncio.Task | None = None
@@ -482,11 +568,21 @@ class MediaServerWebSocket:
if self._session is None or self._session.closed:
self._session = aiohttp.ClientSession()
self._ws = await self._session.ws_connect(
self._ws_url,
heartbeat=30,
timeout=aiohttp.ClientTimeout(total=10),
)
ws_kwargs: dict[str, Any] = {
"heartbeat": 30,
"timeout": aiohttp.ClientTimeout(total=10),
}
if self._token:
# Subprotocol-based auth (preferred by media server v0.3.0+).
# aiohttp negotiates this header; if the server doesn't echo
# it back (older versions), aiohttp still completes the
# handshake — at which point the ?token= query in the URL
# takes over. Safe across both server generations.
ws_kwargs["protocols"] = [f"media-server.token.{self._token}"]
if self._ssl is not None:
ws_kwargs["ssl"] = self._ssl
self._ws = await self._session.ws_connect(self._ws_url, **ws_kwargs)
self._running = True
# Start receive loop
@@ -541,8 +637,9 @@ class MediaServerWebSocket:
track_id = f"{status_data.get('title', '')}-{status_data.get('artist', '')}"
track_hash = hashlib.md5(track_id.encode()).hexdigest()[:8]
token_param = f"token={self._token}&" if self._token else ""
http_scheme = "https" if self._use_ssl else "http"
status_data["album_art_url"] = (
f"http://{self._host}:{self._port}"
f"{http_scheme}://{self._host}:{self._port}"
f"{status_data['album_art_url']}?{token_param}t={track_hash}"
)
self._on_status_update(status_data)
@@ -22,9 +22,13 @@ from .const import (
DOMAIN,
CONF_TOKEN,
CONF_POLL_INTERVAL,
CONF_USE_SSL,
CONF_VERIFY_SSL,
DEFAULT_PORT,
DEFAULT_POLL_INTERVAL,
DEFAULT_NAME,
DEFAULT_USE_SSL,
DEFAULT_VERIFY_SSL,
)
_LOGGER = logging.getLogger(__name__)
@@ -52,6 +56,8 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str,
host=data[CONF_HOST],
port=data[CONF_PORT],
token=data.get(CONF_TOKEN, "") or "",
use_ssl=data.get(CONF_USE_SSL, DEFAULT_USE_SSL),
verify_ssl=data.get(CONF_VERIFY_SSL, DEFAULT_VERIFY_SSL),
)
try:
@@ -134,6 +140,12 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
type=selector.TextSelectorType.PASSWORD
)
),
vol.Optional(
CONF_USE_SSL, default=DEFAULT_USE_SSL
): selector.BooleanSelector(),
vol.Optional(
CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL
): selector.BooleanSelector(),
vol.Optional(CONF_NAME, default=DEFAULT_NAME): selector.TextSelector(
selector.TextSelectorConfig(type=selector.TextSelectorType.TEXT)
),
@@ -9,6 +9,8 @@ CONF_TOKEN = "token"
CONF_POLL_INTERVAL = "poll_interval"
CONF_NAME = "name"
CONF_USE_WEBSOCKET = "use_websocket"
CONF_USE_SSL = "use_ssl"
CONF_VERIFY_SSL = "verify_ssl"
# Default values
DEFAULT_PORT = 8765
@@ -16,6 +18,8 @@ DEFAULT_POLL_INTERVAL = 5
DEFAULT_NAME = "Remote Media Player"
DEFAULT_USE_WEBSOCKET = True
DEFAULT_RECONNECT_INTERVAL = 5
DEFAULT_USE_SSL = False
DEFAULT_VERIFY_SSL = True
# Displays change rarely (brightness/contrast/input source via physical buttons
# or external automations), so a slow shared poll is plenty. The previous
# per-entity polling produced ~9 calls every 30 s for a single monitor.
@@ -8,5 +8,5 @@
"integration_type": "device",
"iot_class": "local_push",
"requirements": ["aiohttp>=3.8.0"],
"version": "0.3.2"
"version": "0.3.3"
}
@@ -36,11 +36,15 @@ from .const import (
CONF_PORT,
CONF_TOKEN,
CONF_POLL_INTERVAL,
CONF_USE_SSL,
CONF_USE_WEBSOCKET,
CONF_VERIFY_SSL,
DEFAULT_POLL_INTERVAL,
DEFAULT_NAME,
DEFAULT_USE_WEBSOCKET,
DEFAULT_RECONNECT_INTERVAL,
DEFAULT_USE_SSL,
DEFAULT_USE_WEBSOCKET,
DEFAULT_VERIFY_SSL,
)
_LOGGER = logging.getLogger(__name__)
@@ -87,6 +91,8 @@ async def async_setup_entry(
port=entry.data[CONF_PORT],
token=entry.data[CONF_TOKEN],
use_websocket=use_websocket,
use_ssl=entry.data.get(CONF_USE_SSL, DEFAULT_USE_SSL),
verify_ssl=entry.data.get(CONF_VERIFY_SSL, DEFAULT_VERIFY_SSL),
entry=entry,
)
@@ -124,6 +130,8 @@ class MediaPlayerCoordinator(DataUpdateCoordinator[dict[str, Any]]):
port: int,
token: str,
use_websocket: bool = True,
use_ssl: bool = DEFAULT_USE_SSL,
verify_ssl: bool = DEFAULT_VERIFY_SSL,
entry: ConfigEntry | None = None,
) -> None:
"""Initialize the coordinator.
@@ -136,6 +144,8 @@ class MediaPlayerCoordinator(DataUpdateCoordinator[dict[str, Any]]):
port: Server port
token: API token
use_websocket: Whether to use WebSocket for updates
use_ssl: Talk WSS instead of WS
verify_ssl: Verify TLS cert (off for self-signed)
entry: Config entry (for integration reload on scripts change)
"""
super().__init__(
@@ -149,6 +159,8 @@ class MediaPlayerCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self._port = port
self._token = token
self._use_websocket = use_websocket
self._use_ssl = use_ssl
self._verify_ssl = verify_ssl
self._entry = entry
self._ws_client: MediaServerWebSocket | None = None
self._ws_connected = False
@@ -173,6 +185,8 @@ class MediaPlayerCoordinator(DataUpdateCoordinator[dict[str, Any]]):
on_disconnect=self._handle_ws_disconnect,
on_scripts_changed=self._handle_ws_scripts_changed,
on_foreground_update=self._handle_ws_foreground_update,
use_ssl=self._use_ssl,
verify_ssl=self._verify_ssl,
)
if await self._ws_client.connect():
@@ -1,6 +1,13 @@
execute_script:
name: Execute Script
description: Execute a pre-defined script on the media server
description: >-
Execute a pre-defined script on one or more Remote Media Player hubs.
If no target is selected, the script runs on ALL configured hubs.
target:
device:
integration: remote_media_player
entity:
integration: remote_media_player
fields:
script_name:
name: Script Name
@@ -16,3 +23,22 @@ execute_script:
example: '{"level": 75, "monitor": "primary"}'
selector:
object:
play_media_file:
name: Play Media File
description: >-
Start playback of a local media file on one or more Remote Media Player hubs.
If no target is selected, playback starts on ALL configured hubs.
target:
device:
integration: remote_media_player
entity:
integration: remote_media_player
fields:
file_path:
name: File Path
description: Absolute path to the media file on the target hub
required: true
example: "C:/Media/movie.mp4"
selector:
text:
@@ -8,6 +8,8 @@
"host": "Host",
"port": "Port",
"token": "API Token",
"use_ssl": "Use HTTPS",
"verify_ssl": "Verify TLS certificate",
"name": "Name",
"poll_interval": "Poll Interval"
},
@@ -15,6 +17,8 @@
"host": "Hostname or IP address of the Media Server",
"port": "Port number (default: 8765)",
"token": "API authentication token from the server configuration. Leave blank if the server runs without authentication.",
"use_ssl": "Talk to the server over HTTPS/WSS. The server must be configured with ssl_certfile and ssl_keyfile in config.yaml.",
"verify_ssl": "Verify the server's TLS certificate chain. Turn off only if the server uses a self-signed certificate on a trusted LAN.",
"name": "Display name for this media player",
"poll_interval": "How often to poll for status updates (seconds)"
}
@@ -73,15 +77,25 @@
"services": {
"execute_script": {
"name": "Execute Script",
"description": "Execute a pre-defined script on the media server.",
"description": "Execute a pre-defined script on one or more Remote Media Player hubs. If no target is selected, the script runs on all configured hubs.",
"fields": {
"script_name": {
"name": "Script Name",
"description": "Name of the script to execute (as defined in server config)"
},
"args": {
"name": "Arguments",
"description": "Optional list of arguments to pass to the script"
"params": {
"name": "Parameters",
"description": "Optional named parameters to pass to the script (validated against script schema)"
}
}
},
"play_media_file": {
"name": "Play Media File",
"description": "Start playback of a local media file on one or more Remote Media Player hubs. If no target is selected, playback starts on all configured hubs.",
"fields": {
"file_path": {
"name": "File Path",
"description": "Absolute path to the media file on the target hub"
}
}
}
@@ -8,6 +8,8 @@
"host": "Host",
"port": "Port",
"token": "API Token",
"use_ssl": "Use HTTPS",
"verify_ssl": "Verify TLS certificate",
"name": "Name",
"poll_interval": "Poll Interval"
},
@@ -15,6 +17,8 @@
"host": "Hostname or IP address of the Media Server",
"port": "Port number (default: 8765)",
"token": "API authentication token from the server configuration. Leave blank if the server runs without authentication.",
"use_ssl": "Talk to the server over HTTPS/WSS. The server must be configured with ssl_certfile and ssl_keyfile in config.yaml.",
"verify_ssl": "Verify the server's TLS certificate chain. Turn off only if the server uses a self-signed certificate on a trusted LAN.",
"name": "Display name for this media player",
"poll_interval": "How often to poll for status updates (seconds)"
}
@@ -73,15 +77,25 @@
"services": {
"execute_script": {
"name": "Execute Script",
"description": "Execute a pre-defined script on the media server.",
"description": "Execute a pre-defined script on one or more Remote Media Player hubs. If no target is selected, the script runs on all configured hubs.",
"fields": {
"script_name": {
"name": "Script Name",
"description": "Name of the script to execute (as defined in server config)"
},
"args": {
"name": "Arguments",
"description": "Optional list of arguments to pass to the script"
"params": {
"name": "Parameters",
"description": "Optional named parameters to pass to the script (validated against script schema)"
}
}
},
"play_media_file": {
"name": "Play Media File",
"description": "Start playback of a local media file on one or more Remote Media Player hubs. If no target is selected, playback starts on all configured hubs.",
"fields": {
"file_path": {
"name": "File Path",
"description": "Absolute path to the media file on the target hub"
}
}
}
@@ -8,6 +8,8 @@
"host": "Хост",
"port": "Порт",
"token": "API токен",
"use_ssl": "Использовать HTTPS",
"verify_ssl": "Проверять TLS-сертификат",
"name": "Название",
"poll_interval": "Интервал опроса"
},
@@ -15,6 +17,8 @@
"host": "Имя хоста или IP-адрес Media Server",
"port": "Номер порта (по умолчанию: 8765)",
"token": "Токен аутентификации из конфигурации сервера. Оставьте пустым, если сервер работает без аутентификации.",
"use_ssl": "Подключаться к серверу по HTTPS/WSS. Сервер должен быть настроен с параметрами ssl_certfile и ssl_keyfile в config.yaml.",
"verify_ssl": "Проверять цепочку TLS-сертификатов сервера. Отключайте только если сервер использует самоподписанный сертификат в доверенной локальной сети.",
"name": "Отображаемое имя медиаплеера",
"poll_interval": "Частота опроса статуса (в секундах)"
}
@@ -73,15 +77,25 @@
"services": {
"execute_script": {
"name": "Выполнить скрипт",
"description": "Выполнить предопределённый скрипт на медиасервере.",
"description": "Выполнить предопределённый скрипт на одном или нескольких хабах Remote Media Player. Если цель не выбрана, скрипт выполнится на всех настроенных хабах.",
"fields": {
"script_name": {
"name": "Имя скрипта",
"description": "Имя скрипта для выполнения (из конфигурации сервера)"
},
"args": {
"name": "Аргументы",
"description": "Необязательный список аргументов для передачи скрипту"
"params": {
"name": "Параметры",
"description": "Необязательные именованные параметры для скрипта (проверяются по схеме скрипта)"
}
}
},
"play_media_file": {
"name": "Воспроизвести медиафайл",
"description": "Запустить воспроизведение локального медиафайла на одном или нескольких хабах Remote Media Player. Если цель не выбрана, воспроизведение запустится на всех настроенных хабах.",
"fields": {
"file_path": {
"name": "Путь к файлу",
"description": "Абсолютный путь к медиафайлу на целевом хабе"
}
}
}