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) ## v0.3.2 (2026-05-18)
### 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))
### Features ### 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)) - **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.
- **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)) - `services.yaml` declares `target:` with `integration: remote_media_player`, and the voluptuous schemas accept the `device_id` / `entity_id` / `area_id` keys HA injects.
- **New select entities**: - **`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.
- `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))
### Performance ### Bug Fixes
- `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)) - **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> 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).
<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>
@@ -207,17 +207,28 @@ automation:
### Execute Script Service ### 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 ```yaml
# Run on a single hub
service: remote_media_player.execute_script service: remote_media_player.execute_script
target:
device_id: <device id of the hub>
data: data:
script_name: "echo_test" script_name: "echo_test"
args: params:
- "arg1" message: "hello"
- "arg2"
# 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 ## Lovelace Card Examples
### Basic Media Control Card ### Basic Media Control Card
+108 -10
View File
@@ -8,9 +8,11 @@ from typing import Any
import voluptuous as vol import voluptuous as vol
from homeassistant.config_entries import ConfigEntry 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.core import HomeAssistant, ServiceCall
from homeassistant.helpers import config_validation as cv 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 .api_client import MediaServerClient, MediaServerError
from .const import ( from .const import (
@@ -20,6 +22,10 @@ from .const import (
CONF_HOST, CONF_HOST,
CONF_PORT, CONF_PORT,
CONF_TOKEN, CONF_TOKEN,
CONF_USE_SSL,
CONF_VERIFY_SSL,
DEFAULT_USE_SSL,
DEFAULT_VERIFY_SSL,
DOMAIN, DOMAIN,
SERVICE_EXECUTE_SCRIPT, SERVICE_EXECUTE_SCRIPT,
SERVICE_PLAY_MEDIA_FILE, SERVICE_PLAY_MEDIA_FILE,
@@ -39,11 +45,20 @@ PLATFORMS: list[Platform] = [
Platform.SELECT, 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 schema for execute_script
SERVICE_EXECUTE_SCRIPT_SCHEMA = vol.Schema( SERVICE_EXECUTE_SCRIPT_SCHEMA = vol.Schema(
{ {
vol.Required(ATTR_SCRIPT_NAME): cv.string, vol.Required(ATTR_SCRIPT_NAME): cv.string,
vol.Optional(ATTR_SCRIPT_PARAMS, default={}): dict, 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( SERVICE_PLAY_MEDIA_FILE_SCHEMA = vol.Schema(
{ {
vol.Required(ATTR_FILE_PATH): cv.string, 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: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Remote Media Player from a config entry. """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) _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( client = MediaServerClient(
host=entry.data[CONF_HOST], host=entry.data[CONF_HOST],
port=entry.data[CONF_PORT], port=entry.data[CONF_PORT],
token=entry.data[CONF_TOKEN], 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 # Verify connection
@@ -108,17 +193,23 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
# Register services if not already registered # Register services if not already registered
if not hass.services.has_service(DOMAIN, SERVICE_EXECUTE_SCRIPT): if not hass.services.has_service(DOMAIN, SERVICE_EXECUTE_SCRIPT):
async def async_execute_script(call: ServiceCall) -> dict[str, Any]: 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_name = call.data[ATTR_SCRIPT_NAME]
script_params = call.data.get(ATTR_SCRIPT_PARAMS, {}) script_params = call.data.get(ATTR_SCRIPT_PARAMS, {})
target_entries = _resolve_entry_ids(hass, call)
_LOGGER.debug( _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: dict[str, Any] = {}
results = {} for entry_id in target_entries:
for entry_id, data in hass.data[DOMAIN].items(): data = hass.data[DOMAIN].get(entry_id)
if data is None:
continue
client: MediaServerClient = data["client"] client: MediaServerClient = data["client"]
try: try:
result = await client.execute_script(script_name, script_params) 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: async def async_play_media_file(call: ServiceCall) -> None:
"""Handle play_media_file service call.""" """Handle play_media_file service call."""
file_path = call.data[ATTR_FILE_PATH] 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 in target_entries:
for entry_id, data in hass.data[DOMAIN].items(): data = hass.data[DOMAIN].get(entry_id)
if data is None:
continue
client: MediaServerClient = data["client"] client: MediaServerClient = data["client"]
try: try:
await client.play_media_file(file_path) await client.play_media_file(file_path)
@@ -5,6 +5,7 @@ from __future__ import annotations
import asyncio import asyncio
import hashlib import hashlib
import logging import logging
import uuid
from collections.abc import Callable from collections.abc import Callable
from typing import Any from typing import Any
@@ -55,6 +56,18 @@ class MediaServerAuthError(MediaServerError):
"""Exception for authentication errors.""" """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: class MediaServerClient:
"""Client for the Media Server REST API.""" """Client for the Media Server REST API."""
@@ -64,6 +77,8 @@ class MediaServerClient:
port: int, port: int,
token: str, token: str,
session: aiohttp.ClientSession | None = None, session: aiohttp.ClientSession | None = None,
use_ssl: bool = False,
verify_ssl: bool = True,
) -> None: ) -> None:
"""Initialize the client. """Initialize the client.
@@ -72,13 +87,22 @@ class MediaServerClient:
port: Server port port: Server port
token: API authentication token token: API authentication token
session: Optional aiohttp session (will create one if not provided) 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._host = host
self._port = int(port) # Ensure port is an integer self._port = int(port) # Ensure port is an integer
self._token = token self._token = token
self._session = session self._session = session
self._own_session = session is None 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: async def _ensure_session(self) -> aiohttp.ClientSession:
"""Ensure we have an aiohttp session.""" """Ensure we have an aiohttp session."""
@@ -98,8 +122,18 @@ class MediaServerClient:
When no token is configured the media server runs in anonymous mode When no token is configured the media server runs in anonymous mode
(``auth.auth_enabled()`` returns False), so we omit the Authorization (``auth.auth_enabled()`` returns False), so we omit the Authorization
header entirely rather than sending ``Bearer `` with an empty value. 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: if self._token:
headers["Authorization"] = f"Bearer {self._token}" headers["Authorization"] = f"Bearer {self._token}"
return headers return headers
@@ -129,17 +163,38 @@ class MediaServerClient:
""" """
session = await self._ensure_session() session = await self._ensure_session()
url = f"{self._base_url}{endpoint}" 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: try:
timeout = aiohttp.ClientTimeout(total=10) timeout = aiohttp.ClientTimeout(total=10)
async with session.request( 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: ) as response:
if response.status == 401: if response.status == 401:
raise MediaServerAuthError("Invalid API token") raise MediaServerAuthError("Invalid API token")
if response.status == 403: if response.status == 403:
raise MediaServerAuthError("Access forbidden") 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() response.raise_for_status()
return await response.json() return await response.json()
@@ -307,6 +362,11 @@ class MediaServerClient:
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Execute a script on the server. """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: Args:
script_name: Name of the script to execute script_name: Name of the script to execute
params: Optional named parameters (validated against script schema) params: Optional named parameters (validated against script schema)
@@ -316,7 +376,17 @@ class MediaServerClient:
""" """
endpoint = f"{API_SCRIPTS_EXECUTE}/{script_name}" endpoint = f"{API_SCRIPTS_EXECUTE}/{script_name}"
json_data = {"params": params or {}} 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]]: async def get_media_folders(self) -> dict[str, dict[str, Any]]:
"""Get configured media folders. """Get configured media folders.
@@ -324,7 +394,12 @@ class MediaServerClient:
Returns: Returns:
Dictionary of folders with folder_id as key and folder config as value 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( async def browse_folder(
self, folder_id: str, path: str = "", offset: int = 0, limit: int = 100 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_disconnect: Callable[[], None] | None = None,
on_scripts_changed: Callable[[], None] | None = None, on_scripts_changed: Callable[[], None] | None = None,
on_foreground_update: Callable[[dict[str, Any]], None] | None = None, on_foreground_update: Callable[[dict[str, Any]], None] | None = None,
use_ssl: bool = False,
verify_ssl: bool = True,
) -> None: ) -> None:
"""Initialize the WebSocket client. """Initialize the WebSocket client.
@@ -454,6 +531,8 @@ class MediaServerWebSocket:
on_disconnect: Callback when connection lost on_disconnect: Callback when connection lost
on_scripts_changed: Callback when scripts have changed on_scripts_changed: Callback when scripts have changed
on_foreground_update: Callback when foreground process changes 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._host = host
self._port = int(port) self._port = int(port)
@@ -462,11 +541,18 @@ class MediaServerWebSocket:
self._on_disconnect = on_disconnect self._on_disconnect = on_disconnect
self._on_scripts_changed = on_scripts_changed self._on_scripts_changed = on_scripts_changed
self._on_foreground_update = on_foreground_update 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 # The server's WS endpoint accepts an unauthenticated connection when
# api_tokens is empty (see media.py:websocket_endpoint), so we only # 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 "" 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._session: aiohttp.ClientSession | None = None
self._ws: aiohttp.ClientWebSocketResponse | None = None self._ws: aiohttp.ClientWebSocketResponse | None = None
self._receive_task: asyncio.Task | None = None self._receive_task: asyncio.Task | None = None
@@ -482,11 +568,21 @@ class MediaServerWebSocket:
if self._session is None or self._session.closed: if self._session is None or self._session.closed:
self._session = aiohttp.ClientSession() self._session = aiohttp.ClientSession()
self._ws = await self._session.ws_connect( ws_kwargs: dict[str, Any] = {
self._ws_url, "heartbeat": 30,
heartbeat=30, "timeout": aiohttp.ClientTimeout(total=10),
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 self._running = True
# Start receive loop # Start receive loop
@@ -541,8 +637,9 @@ class MediaServerWebSocket:
track_id = f"{status_data.get('title', '')}-{status_data.get('artist', '')}" track_id = f"{status_data.get('title', '')}-{status_data.get('artist', '')}"
track_hash = hashlib.md5(track_id.encode()).hexdigest()[:8] track_hash = hashlib.md5(track_id.encode()).hexdigest()[:8]
token_param = f"token={self._token}&" if self._token else "" token_param = f"token={self._token}&" if self._token else ""
http_scheme = "https" if self._use_ssl else "http"
status_data["album_art_url"] = ( 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}" f"{status_data['album_art_url']}?{token_param}t={track_hash}"
) )
self._on_status_update(status_data) self._on_status_update(status_data)
@@ -22,9 +22,13 @@ from .const import (
DOMAIN, DOMAIN,
CONF_TOKEN, CONF_TOKEN,
CONF_POLL_INTERVAL, CONF_POLL_INTERVAL,
CONF_USE_SSL,
CONF_VERIFY_SSL,
DEFAULT_PORT, DEFAULT_PORT,
DEFAULT_POLL_INTERVAL, DEFAULT_POLL_INTERVAL,
DEFAULT_NAME, DEFAULT_NAME,
DEFAULT_USE_SSL,
DEFAULT_VERIFY_SSL,
) )
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -52,6 +56,8 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str,
host=data[CONF_HOST], host=data[CONF_HOST],
port=data[CONF_PORT], port=data[CONF_PORT],
token=data.get(CONF_TOKEN, "") or "", 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: try:
@@ -134,6 +140,12 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
type=selector.TextSelectorType.PASSWORD 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( vol.Optional(CONF_NAME, default=DEFAULT_NAME): selector.TextSelector(
selector.TextSelectorConfig(type=selector.TextSelectorType.TEXT) selector.TextSelectorConfig(type=selector.TextSelectorType.TEXT)
), ),
@@ -9,6 +9,8 @@ CONF_TOKEN = "token"
CONF_POLL_INTERVAL = "poll_interval" CONF_POLL_INTERVAL = "poll_interval"
CONF_NAME = "name" CONF_NAME = "name"
CONF_USE_WEBSOCKET = "use_websocket" CONF_USE_WEBSOCKET = "use_websocket"
CONF_USE_SSL = "use_ssl"
CONF_VERIFY_SSL = "verify_ssl"
# Default values # Default values
DEFAULT_PORT = 8765 DEFAULT_PORT = 8765
@@ -16,6 +18,8 @@ DEFAULT_POLL_INTERVAL = 5
DEFAULT_NAME = "Remote Media Player" DEFAULT_NAME = "Remote Media Player"
DEFAULT_USE_WEBSOCKET = True DEFAULT_USE_WEBSOCKET = True
DEFAULT_RECONNECT_INTERVAL = 5 DEFAULT_RECONNECT_INTERVAL = 5
DEFAULT_USE_SSL = False
DEFAULT_VERIFY_SSL = True
# Displays change rarely (brightness/contrast/input source via physical buttons # Displays change rarely (brightness/contrast/input source via physical buttons
# or external automations), so a slow shared poll is plenty. The previous # 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. # per-entity polling produced ~9 calls every 30 s for a single monitor.
@@ -8,5 +8,5 @@
"integration_type": "device", "integration_type": "device",
"iot_class": "local_push", "iot_class": "local_push",
"requirements": ["aiohttp>=3.8.0"], "requirements": ["aiohttp>=3.8.0"],
"version": "0.3.2" "version": "0.3.3"
} }
@@ -36,11 +36,15 @@ from .const import (
CONF_PORT, CONF_PORT,
CONF_TOKEN, CONF_TOKEN,
CONF_POLL_INTERVAL, CONF_POLL_INTERVAL,
CONF_USE_SSL,
CONF_USE_WEBSOCKET, CONF_USE_WEBSOCKET,
CONF_VERIFY_SSL,
DEFAULT_POLL_INTERVAL, DEFAULT_POLL_INTERVAL,
DEFAULT_NAME, DEFAULT_NAME,
DEFAULT_USE_WEBSOCKET,
DEFAULT_RECONNECT_INTERVAL, DEFAULT_RECONNECT_INTERVAL,
DEFAULT_USE_SSL,
DEFAULT_USE_WEBSOCKET,
DEFAULT_VERIFY_SSL,
) )
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -87,6 +91,8 @@ async def async_setup_entry(
port=entry.data[CONF_PORT], port=entry.data[CONF_PORT],
token=entry.data[CONF_TOKEN], token=entry.data[CONF_TOKEN],
use_websocket=use_websocket, 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, entry=entry,
) )
@@ -124,6 +130,8 @@ class MediaPlayerCoordinator(DataUpdateCoordinator[dict[str, Any]]):
port: int, port: int,
token: str, token: str,
use_websocket: bool = True, use_websocket: bool = True,
use_ssl: bool = DEFAULT_USE_SSL,
verify_ssl: bool = DEFAULT_VERIFY_SSL,
entry: ConfigEntry | None = None, entry: ConfigEntry | None = None,
) -> None: ) -> None:
"""Initialize the coordinator. """Initialize the coordinator.
@@ -136,6 +144,8 @@ class MediaPlayerCoordinator(DataUpdateCoordinator[dict[str, Any]]):
port: Server port port: Server port
token: API token token: API token
use_websocket: Whether to use WebSocket for updates 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) entry: Config entry (for integration reload on scripts change)
""" """
super().__init__( super().__init__(
@@ -149,6 +159,8 @@ class MediaPlayerCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self._port = port self._port = port
self._token = token self._token = token
self._use_websocket = use_websocket self._use_websocket = use_websocket
self._use_ssl = use_ssl
self._verify_ssl = verify_ssl
self._entry = entry self._entry = entry
self._ws_client: MediaServerWebSocket | None = None self._ws_client: MediaServerWebSocket | None = None
self._ws_connected = False self._ws_connected = False
@@ -173,6 +185,8 @@ class MediaPlayerCoordinator(DataUpdateCoordinator[dict[str, Any]]):
on_disconnect=self._handle_ws_disconnect, on_disconnect=self._handle_ws_disconnect,
on_scripts_changed=self._handle_ws_scripts_changed, on_scripts_changed=self._handle_ws_scripts_changed,
on_foreground_update=self._handle_ws_foreground_update, on_foreground_update=self._handle_ws_foreground_update,
use_ssl=self._use_ssl,
verify_ssl=self._verify_ssl,
) )
if await self._ws_client.connect(): if await self._ws_client.connect():
@@ -1,6 +1,13 @@
execute_script: execute_script:
name: 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: fields:
script_name: script_name:
name: Script Name name: Script Name
@@ -16,3 +23,22 @@ execute_script:
example: '{"level": 75, "monitor": "primary"}' example: '{"level": 75, "monitor": "primary"}'
selector: selector:
object: 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", "host": "Host",
"port": "Port", "port": "Port",
"token": "API Token", "token": "API Token",
"use_ssl": "Use HTTPS",
"verify_ssl": "Verify TLS certificate",
"name": "Name", "name": "Name",
"poll_interval": "Poll Interval" "poll_interval": "Poll Interval"
}, },
@@ -15,6 +17,8 @@
"host": "Hostname or IP address of the Media Server", "host": "Hostname or IP address of the Media Server",
"port": "Port number (default: 8765)", "port": "Port number (default: 8765)",
"token": "API authentication token from the server configuration. Leave blank if the server runs without authentication.", "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", "name": "Display name for this media player",
"poll_interval": "How often to poll for status updates (seconds)" "poll_interval": "How often to poll for status updates (seconds)"
} }
@@ -73,15 +77,25 @@
"services": { "services": {
"execute_script": { "execute_script": {
"name": "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": { "fields": {
"script_name": { "script_name": {
"name": "Script Name", "name": "Script Name",
"description": "Name of the script to execute (as defined in server config)" "description": "Name of the script to execute (as defined in server config)"
}, },
"args": { "params": {
"name": "Arguments", "name": "Parameters",
"description": "Optional list of arguments to pass to the script" "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", "host": "Host",
"port": "Port", "port": "Port",
"token": "API Token", "token": "API Token",
"use_ssl": "Use HTTPS",
"verify_ssl": "Verify TLS certificate",
"name": "Name", "name": "Name",
"poll_interval": "Poll Interval" "poll_interval": "Poll Interval"
}, },
@@ -15,6 +17,8 @@
"host": "Hostname or IP address of the Media Server", "host": "Hostname or IP address of the Media Server",
"port": "Port number (default: 8765)", "port": "Port number (default: 8765)",
"token": "API authentication token from the server configuration. Leave blank if the server runs without authentication.", "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", "name": "Display name for this media player",
"poll_interval": "How often to poll for status updates (seconds)" "poll_interval": "How often to poll for status updates (seconds)"
} }
@@ -73,15 +77,25 @@
"services": { "services": {
"execute_script": { "execute_script": {
"name": "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": { "fields": {
"script_name": { "script_name": {
"name": "Script Name", "name": "Script Name",
"description": "Name of the script to execute (as defined in server config)" "description": "Name of the script to execute (as defined in server config)"
}, },
"args": { "params": {
"name": "Arguments", "name": "Parameters",
"description": "Optional list of arguments to pass to the script" "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": "API токен",
"use_ssl": "Использовать HTTPS",
"verify_ssl": "Проверять TLS-сертификат",
"name": "Название", "name": "Название",
"poll_interval": "Интервал опроса" "poll_interval": "Интервал опроса"
}, },
@@ -15,6 +17,8 @@
"host": "Имя хоста или IP-адрес Media Server", "host": "Имя хоста или IP-адрес Media Server",
"port": "Номер порта (по умолчанию: 8765)", "port": "Номер порта (по умолчанию: 8765)",
"token": "Токен аутентификации из конфигурации сервера. Оставьте пустым, если сервер работает без аутентификации.", "token": "Токен аутентификации из конфигурации сервера. Оставьте пустым, если сервер работает без аутентификации.",
"use_ssl": "Подключаться к серверу по HTTPS/WSS. Сервер должен быть настроен с параметрами ssl_certfile и ssl_keyfile в config.yaml.",
"verify_ssl": "Проверять цепочку TLS-сертификатов сервера. Отключайте только если сервер использует самоподписанный сертификат в доверенной локальной сети.",
"name": "Отображаемое имя медиаплеера", "name": "Отображаемое имя медиаплеера",
"poll_interval": "Частота опроса статуса (в секундах)" "poll_interval": "Частота опроса статуса (в секундах)"
} }
@@ -73,15 +77,25 @@
"services": { "services": {
"execute_script": { "execute_script": {
"name": "Выполнить скрипт", "name": "Выполнить скрипт",
"description": "Выполнить предопределённый скрипт на медиасервере.", "description": "Выполнить предопределённый скрипт на одном или нескольких хабах Remote Media Player. Если цель не выбрана, скрипт выполнится на всех настроенных хабах.",
"fields": { "fields": {
"script_name": { "script_name": {
"name": "Имя скрипта", "name": "Имя скрипта",
"description": "Имя скрипта для выполнения (из конфигурации сервера)" "description": "Имя скрипта для выполнения (из конфигурации сервера)"
}, },
"args": { "params": {
"name": "Аргументы", "name": "Параметры",
"description": "Необязательный список аргументов для передачи скрипту" "description": "Необязательные именованные параметры для скрипта (проверяются по схеме скрипта)"
}
}
},
"play_media_file": {
"name": "Воспроизвести медиафайл",
"description": "Запустить воспроизведение локального медиафайла на одном или нескольких хабах Remote Media Player. Если цель не выбрана, воспроизведение запустится на всех настроенных хабах.",
"fields": {
"file_path": {
"name": "Путь к файлу",
"description": "Абсолютный путь к медиафайлу на целевом хабе"
} }
} }
} }