10 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
alexei.dolgolyov 9d277276b8 feat(foreground): foreground process sensors + translation key migration
Adds Home Assistant entities for the foreground-process feature shipped
in the media server, plus migrates existing display entities to use HA
translation keys (strings.json / translations/*) so per-language UI text
flows through the standard locale mechanism.

Foreground entities (all share one HA "Foreground" device linked to the
hub via via_device):
- sensor.foreground_process — process name as state + full payload
  (pid, exec path, window title, fullscreen flag, monitor, geometry,
  is_browser, browser_page_title, browser_url, error) as attributes
- sensor.window_title, sensor.pid, sensor.foreground_monitor,
  sensor.process_started (TIMESTAMP device class)
- binary_sensor.fullscreen, binary_sensor.minimized

Data flow:
- ForegroundCoordinator polls GET /api/foreground every 5s (HTTP fallback)
- media_player's WebSocket receiver forwards `foreground` /
  `foreground_update` push frames into the coordinator via
  apply_websocket_snapshot, so sensors update in near-real-time when WS
  is connected and fall back to polling otherwise

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-05-18 03:13:23 +03:00
alexei.dolgolyov ab0585278c feat: shared DisplayCoordinator + optional API token
- Introduce DisplayCoordinator polling /api/display/monitors once per
  cycle and fan out to all per-display entities via CoordinatorEntity.
  Removes ~9x redundant requests per polling cycle that came from each
  binary_sensor/number/select/sensor/switch entity calling
  get_display_monitors() in its own async_update.
- Optimistic write-through via coordinator.apply_optimistic(...) keeps
  sibling entities in sync after slider/select writes without an extra
  network round-trip.
- Make CONF_TOKEN optional. The media server already supports running
  without auth (auth_enabled() returns False when api_tokens is empty),
  so the integration omits the Authorization header and ?token= query
  from REST/WS/album-art URLs when no token is configured. Server-side
  auth-enabled rejections still surface as invalid_auth in the UI.
- Bump manifest version to 0.3.2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 23:46:26 +03:00
alexei.dolgolyov 68e338de4e chore: release v0.3.0
Release / release (push) Successful in 3s
2026-05-15 14:52:48 +03:00
alexei.dolgolyov 4156dedf5e feat(displays): per-display devices + DDC/CI capability entities
Restructure how displays are exposed in Home Assistant:

Each physical monitor is now its own HA device linked to the media-server
hub via `via_device`. The hub keeps the media_player + script buttons; per-
display devices hold the power switch, brightness slider, and the new
capability entities. This lets users place displays in their own area/room
and keeps related entities grouped together in the UI.

New platforms:
- sensor: DisplayResolutionSensor (diagnostic, from EDID)
- binary_sensor: DisplayPrimaryBinarySensor + DisplayPowerControlBinarySensor
  (both diagnostic; help users see why a power switch is or isn't created)
- select: DisplayInputSourceSelect (HDMI1/DP1/...), DisplayColorPresetSelect
  (color temperature), DisplayPictureModeSelect (VCP 0xDC scene modes)
- number: added DisplayContrastNumber alongside brightness

Other changes:
- display_device helper centralises the per-display DeviceInfo; pulls real
  manufacturer/model from EDID; device name no longer prepends the hub
  title since via_device already shows the hierarchy.
- api_client gains set_display_{contrast,input_source,color_preset,picture_mode}
  and stops forcing `?refresh=true` on every poll so HA can ride the
  server's TTL cache instead of triggering full DDC/CI probes per entity.
- select / number entities now check the server's `success` flag and re-
  sync from the actual monitor state when a write was silently rejected
  (some monitors honor reads but ignore writes for certain DDC/CI codes).

Bumps manifest.json to 0.3.0 - the device topology change is user-visible
and existing brightness/power entities migrate to per-display devices on
first reload (unique_ids are preserved).
2026-05-15 14:46:50 +03:00
alexei.dolgolyov b0d98a9d45 chore: bump manifest.json version to 0.1.1
Release / release (push) Successful in 4s
2026-03-26 21:41:06 +03:00
alexei.dolgolyov d0d4958843 chore: update release notes for v0.1.1
Release / release (push) Successful in 4s
2026-03-26 21:36:27 +03:00
alexei.dolgolyov de4b7cf9b4 feat: replace script args with typed named parameters
- Change execute_script API from positional args list to named params dict
- Update service schema, API client, and constants
- Add execute_script service documentation to README
2026-03-26 21:35:51 +03:00
22 changed files with 1565 additions and 236 deletions
+42
View File
@@ -103,6 +103,48 @@ Button entities for each script defined on your Media Server:
- Shutdown, restart, sleep, hibernate
- Custom scripts
### Execute Script Service
Call `remote_media_player.execute_script` to run any server-defined script with typed parameters:
```yaml
service: remote_media_player.execute_script
data:
script_name: set_brightness
params:
level: 75
monitor: primary
```
Parameters are validated against the script's schema on the server. Scripts define their parameters in `config.yaml`:
```yaml
scripts:
set_brightness:
command: "python set_brightness.py"
label: "Set Brightness"
icon: "mdi:brightness-6"
timeout: 10
parameters:
level:
type: integer
required: true
min: 0
max: 100
description: "Brightness level (0-100)"
monitor:
type: select
options: ["primary", "secondary", "all"]
default: "primary"
description: "Target monitor"
```
Supported parameter types: `string`, `integer`, `float`, `boolean`, `select`.
Parameters are passed to scripts as environment variables prefixed with `SCRIPT_PARAM_` (e.g., `SCRIPT_PARAM_LEVEL=75`, `SCRIPT_PARAM_MONITOR=primary`).
Scripts without parameters work as before — just omit `params`.
## Example Lovelace Card
```yaml
+11 -45
View File
@@ -1,53 +1,19 @@
## v0.1.0 (2026-03-26)
Initial release of the Remote Media Player custom integration for Home Assistant.
## v0.3.2 (2026-05-18)
### Features
- HACS-ready Home Assistant custom integration for controlling remote PC media playback ([7837714](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/7837714))
- Add turn on / turn off / toggle support for the media player entity ([e66f2f3](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/e66f2f3))
- Add automatic script reload support ([e4eeb2a](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/e4eeb2a))
- Add media browser integration for Home Assistant ([8cbe33e](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/8cbe33e))
- Add display monitor brightness and power control entities ([83153db](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/83153db))
- **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.
### Bug Fixes
- Fix entity not becoming unavailable on server shutdown ([02bdcc5](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/02bdcc5))
- **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.
### Performance
- Reduce WebSocket reconnect interval to 5 seconds ([959c6a4](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/959c6a4))
- Codebase audit fixes: stability and performance ([a37eb46](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/a37eb46))
### 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.
---
### Development / Internal
#### Documentation
- Update README with valid repository URLs ([f2b618a](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/f2b618a))
- Replace GitHub URLs with git.dolgolyov-family.by ([b3624e6](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/b3624e6))
- Update CLAUDE.md with git push rules, versioning rules, and commit approval rules ([725fc02](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/725fc02), [b13aa86](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/b13aa86), [3798833](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/3798833))
#### CI/Build
- Add Gitea release workflow ([6c56576](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/6c56576))
---
<details>
<summary>All Commits</summary>
| Hash | Message | Author |
|------|---------|--------|
| [7837714](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/7837714) | Initial commit: HACS-ready Home Assistant integration | alexei.dolgolyov |
| [f2b618a](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/f2b618a) | Update README with valid GitHub repository URLs | alexei.dolgolyov |
| [725fc02](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/725fc02) | Update CLAUDE.md with git push rules and repo link | alexei.dolgolyov |
| [b3624e6](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/b3624e6) | Replace GitHub URLs with git.dolgolyov-family.by | alexei.dolgolyov |
| [b13aa86](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/b13aa86) | Add versioning rules to CLAUDE.md | alexei.dolgolyov |
| [3798833](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/3798833) | Update CLAUDE.md with commit/push approval rules | alexei.dolgolyov |
| [e66f2f3](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/e66f2f3) | Add turn_on/turn_off/toggle support | alexei.dolgolyov |
| [959c6a4](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/959c6a4) | Reduce WebSocket reconnect interval to 5 seconds | alexei.dolgolyov |
| [e4eeb2a](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/e4eeb2a) | Add automatic script reload support | alexei.dolgolyov |
| [8cbe33e](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/8cbe33e) | Add media browser integration for Home Assistant | alexei.dolgolyov |
| [02bdcc5](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/02bdcc5) | Fix entity not becoming unavailable on server shutdown | alexei.dolgolyov |
| [83153db](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/83153db) | Add display monitor brightness and power control entities | alexei.dolgolyov |
| [a37eb46](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/a37eb46) | Codebase audit fixes: stability and performance | alexei.dolgolyov |
| [6c56576](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/6c56576) | ci: add Gitea release workflow | 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
+142 -17
View File
@@ -8,34 +8,57 @@ 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 (
ATTR_FILE_PATH,
ATTR_SCRIPT_ARGS,
ATTR_SCRIPT_NAME,
ATTR_SCRIPT_PARAMS,
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,
)
from .display_coordinator import DisplayCoordinator
from .foreground_coordinator import ForegroundCoordinator
_LOGGER = logging.getLogger(__name__)
PLATFORMS: list[Platform] = [Platform.MEDIA_PLAYER, Platform.BUTTON, Platform.NUMBER, Platform.SWITCH]
PLATFORMS: list[Platform] = [
Platform.MEDIA_PLAYER,
Platform.BUTTON,
Platform.NUMBER,
Platform.SWITCH,
Platform.SENSOR,
Platform.BINARY_SENSOR,
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_ARGS, default=[]): vol.All(
cv.ensure_list, [cv.string]
),
vol.Optional(ATTR_SCRIPT_PARAMS, default={}): dict,
**_TARGET_FIELDS,
}
)
@@ -43,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.
@@ -59,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
@@ -72,29 +165,54 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
await client.close()
return False
# Create the shared display coordinator BEFORE platform setup so each
# display platform's async_setup_entry can register against the same
# data source instead of polling /api/display/monitors on its own.
display_coordinator = DisplayCoordinator(hass, client)
try:
await display_coordinator.async_config_entry_first_refresh()
except Exception as err: # noqa: BLE001 - first refresh wraps its own errors
_LOGGER.warning("Initial display monitor fetch failed, will retry: %s", err)
# Foreground coordinator — shared by sensor + binary_sensor platforms and
# nudged by the media-player WebSocket receiver when it gets a push.
foreground_coordinator = ForegroundCoordinator(hass, client)
try:
await foreground_coordinator.async_config_entry_first_refresh()
except Exception as err: # noqa: BLE001
_LOGGER.warning("Initial foreground fetch failed, will retry: %s", err)
# Store client in hass.data
hass.data.setdefault(DOMAIN, {})
hass.data[DOMAIN][entry.entry_id] = {
"client": client,
"display_coordinator": display_coordinator,
"foreground_coordinator": foreground_coordinator,
}
# 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_args = call.data.get(ATTR_SCRIPT_ARGS, [])
script_params = call.data.get(ATTR_SCRIPT_PARAMS, {})
target_entries = _resolve_entry_ids(hass, call)
_LOGGER.debug(
"Executing script '%s' with args: %s", script_name, script_args
"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_args)
result = await client.execute_script(script_name, script_params)
results[entry_id] = result
_LOGGER.info(
"Script '%s' executed on %s: success=%s",
@@ -125,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
@@ -31,8 +32,13 @@ from .const import (
API_BROWSER_BROWSE,
API_BROWSER_PLAY,
API_DISPLAY_MONITORS,
API_FOREGROUND,
API_DISPLAY_BRIGHTNESS,
API_DISPLAY_POWER,
API_DISPLAY_CONTRAST,
API_DISPLAY_INPUT_SOURCE,
API_DISPLAY_COLOR_PRESET,
API_DISPLAY_PICTURE_MODE,
)
_LOGGER = logging.getLogger(__name__)
@@ -50,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."""
@@ -59,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.
@@ -67,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."""
@@ -88,11 +117,26 @@ class MediaServerClient:
await self._session.close()
def _get_headers(self) -> dict[str, str]:
"""Get headers for API requests."""
return {
"Authorization": f"Bearer {self._token}",
"""Get headers for API requests.
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",
"X-Request-ID": uuid.uuid4().hex,
}
if self._token:
headers["Authorization"] = f"Bearer {self._token}"
return headers
async def _request(
self,
@@ -119,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()
@@ -174,13 +239,17 @@ class MediaServerClient:
"""
data = await self._request("GET", API_STATUS)
# Convert relative album_art_url to absolute URL with token and cache-buster
# Convert relative album_art_url to absolute URL with cache-buster
# (and token only when auth is enabled on the server side).
if data.get("album_art_url") and data["album_art_url"].startswith("/"):
# Add track info hash to force HA to re-fetch when track changes
import hashlib
track_id = f"{data.get('title', '')}-{data.get('artist', '')}"
track_hash = hashlib.md5(track_id.encode()).hexdigest()[:8]
data["album_art_url"] = f"{self._base_url}{data['album_art_url']}?token={self._token}&t={track_hash}"
token_param = f"token={self._token}&" if self._token else ""
data["album_art_url"] = (
f"{self._base_url}{data['album_art_url']}?{token_param}t={track_hash}"
)
return data
@@ -287,20 +356,37 @@ class MediaServerClient:
return await self._request("GET", API_SCRIPTS_LIST)
async def execute_script(
self, script_name: str, args: list[str] | None = None
self,
script_name: str,
params: dict[str, str | int | float | bool] | None = None,
) -> 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
args: Optional list of arguments to pass to the script
params: Optional named parameters (validated against script schema)
Returns:
Execution result with success, exit_code, stdout, stderr
"""
endpoint = f"{API_SCRIPTS_EXECUTE}/{script_name}"
json_data = {"args": args or []}
return await self._request("POST", endpoint, json_data)
json_data = {"params": params or {}}
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.
@@ -308,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
@@ -346,12 +437,12 @@ class MediaServerClient:
return await self._request("POST", API_BROWSER_PLAY, {"path": file_path})
async def get_display_monitors(self) -> list[dict[str, Any]]:
"""Get list of connected monitors with brightness and power info.
"""Get list of connected monitors with brightness, power, DDC/CI state.
Returns:
List of monitor dicts with id, name, brightness, power_supported, power_on, resolution
Uses the server's short TTL cache so per-entity polling does not pay
the full DDC/CI probe cost on every call.
"""
return await self._request("GET", f"{API_DISPLAY_MONITORS}?refresh=true")
return await self._request("GET", API_DISPLAY_MONITORS)
async def set_display_brightness(self, monitor_id: int, brightness: int) -> dict[str, Any]:
"""Set brightness for a specific monitor.
@@ -381,6 +472,39 @@ class MediaServerClient:
"POST", f"{API_DISPLAY_POWER}/{monitor_id}", {"on": on}
)
async def set_display_contrast(self, monitor_id: int, contrast: int) -> dict[str, Any]:
"""Set DDC/CI contrast for a specific monitor (0-100)."""
return await self._request(
"POST", f"{API_DISPLAY_CONTRAST}/{monitor_id}", {"contrast": contrast}
)
async def set_display_input_source(self, monitor_id: int, source: str) -> dict[str, Any]:
"""Switch a monitor's DDC/CI input source by enum name (e.g. 'HDMI1')."""
return await self._request(
"POST", f"{API_DISPLAY_INPUT_SOURCE}/{monitor_id}", {"source": source}
)
async def set_display_color_preset(self, monitor_id: int, preset: str) -> dict[str, Any]:
"""Apply a DDC/CI color preset by enum name (e.g. 'COLOR_TEMP_6500K')."""
return await self._request(
"POST", f"{API_DISPLAY_COLOR_PRESET}/{monitor_id}", {"preset": preset}
)
async def set_display_picture_mode(self, monitor_id: int, code: int) -> dict[str, Any]:
"""Apply a DDC/CI picture/scene mode (VCP 0xDC) by raw code."""
return await self._request(
"POST", f"{API_DISPLAY_PICTURE_MODE}/{monitor_id}", {"code": code}
)
async def get_foreground(self) -> dict[str, Any]:
"""Get the foreground window/process snapshot.
Returns the structured payload described in the media server's
``ForegroundInfo`` dataclass: process name, window title, fullscreen
flag, owning monitor, geometry, and process start time.
"""
return await self._request("GET", API_FOREGROUND)
class MediaServerWebSocket:
"""WebSocket client for real-time media status updates."""
@@ -393,6 +517,9 @@ class MediaServerWebSocket:
on_status_update: Callable[[dict[str, Any]], None],
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.
@@ -403,6 +530,9 @@ class MediaServerWebSocket:
on_status_update: Callback when status update received
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)
@@ -410,7 +540,19 @@ class MediaServerWebSocket:
self._on_status_update = on_status_update
self._on_disconnect = on_disconnect
self._on_scripts_changed = on_scripts_changed
self._ws_url = f"ws://{host}:{self._port}/api/media/ws?token={token}"
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. 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 ""
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
@@ -426,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
@@ -484,15 +636,20 @@ 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"{status_data['album_art_url']}?token={self._token}&t={track_hash}"
f"{http_scheme}://{self._host}:{self._port}"
f"{status_data['album_art_url']}?{token_param}t={track_hash}"
)
self._on_status_update(status_data)
elif msg_type == "scripts_changed":
_LOGGER.info("Scripts changed notification received")
if self._on_scripts_changed:
self._on_scripts_changed()
elif msg_type in ("foreground", "foreground_update"):
if self._on_foreground_update:
self._on_foreground_update(data.get("data", {}))
elif msg_type == "pong":
_LOGGER.debug("Received pong")
@@ -0,0 +1,122 @@
"""Diagnostic binary sensors per display (primary, DDC/CI power-control support)."""
from __future__ import annotations
import logging
from typing import Any
from homeassistant.components.binary_sensor import BinarySensorEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import EntityCategory
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .display_coordinator import DisplayCoordinator
from .display_device import display_device_info
from .foreground import FOREGROUND_BINARY_SENSORS
from .foreground_coordinator import ForegroundCoordinator
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up display + foreground binary sensor entities."""
store = hass.data[DOMAIN][entry.entry_id]
display_coordinator: DisplayCoordinator = store["display_coordinator"]
foreground_coordinator: ForegroundCoordinator | None = store.get(
"foreground_coordinator"
)
entities: list[Any] = []
if display_coordinator.data:
for monitor in display_coordinator.data.values():
entities.append(
DisplayPrimaryBinarySensor(display_coordinator, entry, monitor)
)
entities.append(
DisplayPowerControlBinarySensor(display_coordinator, entry, monitor)
)
if foreground_coordinator is not None:
entities.extend(
cls(foreground_coordinator, entry) for cls in FOREGROUND_BINARY_SENSORS
)
if entities:
async_add_entities(entities)
_LOGGER.info(
"Added %d binary sensor entities (display + foreground)", len(entities)
)
class _DisplayBinarySensorBase(
CoordinatorEntity[DisplayCoordinator], BinarySensorEntity
):
"""Common boilerplate for per-display diagnostic binary sensors."""
_attr_has_entity_name = True
_attr_entity_category = EntityCategory.DIAGNOSTIC
def __init__(
self,
coordinator: DisplayCoordinator,
entry: ConfigEntry,
monitor: dict[str, Any],
) -> None:
super().__init__(coordinator)
self._monitor_id: int = monitor["id"]
self._attr_device_info = display_device_info(entry, monitor)
@property
def _monitor(self) -> dict[str, Any]:
if self.coordinator.data is None:
return {}
return self.coordinator.data.get(self._monitor_id, {})
class DisplayPrimaryBinarySensor(_DisplayBinarySensorBase):
"""Indicates whether the display is the OS primary monitor."""
_attr_translation_key = "primary_display"
_attr_icon = "mdi:monitor-star"
def __init__(
self,
coordinator: DisplayCoordinator,
entry: ConfigEntry,
monitor: dict[str, Any],
) -> None:
super().__init__(coordinator, entry, monitor)
self._attr_unique_id = f"{entry.entry_id}_display_primary_{self._monitor_id}"
@property
def is_on(self) -> bool:
return bool(self._monitor.get("is_primary"))
class DisplayPowerControlBinarySensor(_DisplayBinarySensorBase):
"""Indicates whether DDC/CI power control is available for this display."""
_attr_translation_key = "power_control_supported"
_attr_icon = "mdi:power-plug"
def __init__(
self,
coordinator: DisplayCoordinator,
entry: ConfigEntry,
monitor: dict[str, Any],
) -> None:
super().__init__(coordinator, entry, monitor)
self._attr_unique_id = (
f"{entry.entry_id}_display_power_supported_{self._monitor_id}"
)
@property
def is_on(self) -> bool:
return bool(self._monitor.get("power_supported"))
@@ -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__)
@@ -44,10 +48,16 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str,
CannotConnect: If connection fails
InvalidAuth: If authentication fails
"""
# Token is optional: the media server can run without auth tokens, in which
# case verify_token() returns "anonymous" and accepts unauthenticated calls.
# If the server *does* have tokens configured, get_status() below will 401
# and we surface that as "invalid_auth" in the UI.
client = MediaServerClient(
host=data[CONF_HOST],
port=data[CONF_PORT],
token=data[CONF_TOKEN],
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:
@@ -125,11 +135,17 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
mode=selector.NumberSelectorMode.BOX,
)
),
vol.Required(CONF_TOKEN): selector.TextSelector(
vol.Optional(CONF_TOKEN, default=""): selector.TextSelector(
selector.TextSelectorConfig(
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)
),
+14 -1
View File
@@ -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,12 @@ 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.
DEFAULT_DISPLAY_POLL_INTERVAL = 30
# API endpoints
API_HEALTH = "/api/health"
@@ -37,9 +45,14 @@ API_WEBSOCKET = "/api/media/ws"
API_BROWSER_FOLDERS = "/api/browser/folders"
API_BROWSER_BROWSE = "/api/browser/browse"
API_BROWSER_PLAY = "/api/browser/play"
API_FOREGROUND = "/api/foreground"
API_DISPLAY_MONITORS = "/api/display/monitors"
API_DISPLAY_BRIGHTNESS = "/api/display/brightness"
API_DISPLAY_POWER = "/api/display/power"
API_DISPLAY_CONTRAST = "/api/display/contrast"
API_DISPLAY_INPUT_SOURCE = "/api/display/input_source"
API_DISPLAY_COLOR_PRESET = "/api/display/color_preset"
API_DISPLAY_PICTURE_MODE = "/api/display/picture_mode"
# Service names
SERVICE_EXECUTE_SCRIPT = "execute_script"
@@ -47,5 +60,5 @@ SERVICE_PLAY_MEDIA_FILE = "play_media_file"
# Service attributes
ATTR_SCRIPT_NAME = "script_name"
ATTR_SCRIPT_ARGS = "args"
ATTR_SCRIPT_PARAMS = "params"
ATTR_FILE_PATH = "file_path"
@@ -0,0 +1,66 @@
"""Shared coordinator for per-display monitor state.
All display platforms (binary_sensor, number, select, sensor, switch) share a
single poll cycle through this coordinator instead of each entity calling
``GET /api/display/monitors`` from its own ``async_update``. With ~9 display
entities per monitor, that change reduces the HTTP load on the media server
from 9x per cycle to 1x per cycle.
"""
from __future__ import annotations
import logging
from datetime import timedelta
from typing import Any
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .api_client import MediaServerClient, MediaServerError
from .const import DEFAULT_DISPLAY_POLL_INTERVAL
_LOGGER = logging.getLogger(__name__)
# Coordinator data: monitor_id -> monitor dict (full payload as returned by
# the media server, indexed for O(1) per-entity lookup).
DisplayData = dict[int, dict[str, Any]]
class DisplayCoordinator(DataUpdateCoordinator[DisplayData]):
"""Polls ``/api/display/monitors`` once and fans out to all display entities."""
def __init__(
self,
hass: HomeAssistant,
client: MediaServerClient,
poll_interval: int = DEFAULT_DISPLAY_POLL_INTERVAL,
) -> None:
super().__init__(
hass,
_LOGGER,
name="Remote Media Player Displays",
update_interval=timedelta(seconds=poll_interval),
)
self.client = client
async def _async_update_data(self) -> DisplayData:
try:
monitors = await self.client.get_display_monitors()
except MediaServerError as err:
raise UpdateFailed(f"Failed to fetch display monitors: {err}") from err
return {monitor["id"]: monitor for monitor in monitors}
def apply_optimistic(self, monitor_id: int, **fields: Any) -> None:
"""Mutate cached monitor data after a successful write and notify entities.
Avoids a network round trip on every slider tick while still keeping
all sibling display entities in sync. The next scheduled refresh
reconciles with the server's authoritative state.
"""
if self.data is None:
return
monitor = self.data.get(monitor_id)
if monitor is None:
return
monitor.update(fields)
self.async_update_listeners()
@@ -0,0 +1,56 @@
"""Helpers for building per-display DeviceInfo.
Each physical monitor is exposed as its own HA device (linked back to the
media-server hub via `via_device`) so that per-display entities (power
switch, brightness, future per-display sensors) cluster together, can be
placed in their own area/room, and participate in device-based automations.
"""
from __future__ import annotations
from typing import Any
from homeassistant.config_entries import ConfigEntry
from homeassistant.helpers.entity import DeviceInfo
from .const import DOMAIN
def display_label(monitor: dict[str, Any]) -> str:
"""Return a user-friendly label for a display monitor.
Resolution is appended when available so that two monitors sharing a
name (e.g. two "Generic PnP Monitor" entries) remain distinguishable.
"""
name = monitor.get("name") or f"Monitor {monitor['id']}"
resolution = monitor.get("resolution")
if resolution:
return f"{name} ({resolution})"
return name
def display_device_identifier(entry: ConfigEntry, monitor_id: int) -> tuple[str, str]:
"""Return the stable identifier tuple for a per-display device."""
return (DOMAIN, f"{entry.entry_id}_display_{monitor_id}")
def display_device_info(entry: ConfigEntry, monitor: dict[str, Any]) -> DeviceInfo:
"""Build DeviceInfo for a per-display device linked to the hub.
Prefers the manufacturer/model reported by the monitor's EDID; falls back
to integration-level defaults so devices still appear sensibly even when
EDID parsing returns blanks.
"""
manufacturer = (monitor.get("manufacturer") or "").strip() or "Remote Media Player"
model = (monitor.get("model") or "").strip() or "Display"
return DeviceInfo(
identifiers={display_device_identifier(entry, monitor["id"])},
via_device=(DOMAIN, entry.entry_id),
# HA's device tree already shows the parent hub above its children
# via `via_device`, so re-stating the entry title here would just
# duplicate the hub name on every child row.
name=display_label(monitor),
manufacturer=manufacturer,
model=model,
)
@@ -0,0 +1,225 @@
"""Foreground process sensor and binary-sensor entities."""
from __future__ import annotations
import logging
from datetime import datetime, timezone
from typing import Any
from homeassistant.components.binary_sensor import BinarySensorEntity
from homeassistant.components.sensor import SensorDeviceClass, SensorEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .foreground_coordinator import ForegroundCoordinator
_LOGGER = logging.getLogger(__name__)
def _foreground_device_info(entry: ConfigEntry) -> DeviceInfo:
"""All foreground entities share one HA device, linked to the hub."""
return DeviceInfo(
identifiers={(DOMAIN, f"{entry.entry_id}_foreground")},
via_device=(DOMAIN, entry.entry_id),
name="Foreground",
manufacturer="Remote Media Player",
model="Foreground Process",
)
class _ForegroundEntityBase(CoordinatorEntity[ForegroundCoordinator]):
"""Boilerplate shared by every foreground entity."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: ForegroundCoordinator,
entry: ConfigEntry,
) -> None:
super().__init__(coordinator)
self._entry = entry
self._attr_device_info = _foreground_device_info(entry)
@property
def _data(self) -> dict[str, Any]:
return self.coordinator.data or {}
@property
def available(self) -> bool:
# Coordinator availability covers HTTP failures; the per-platform
# ``available`` flag in the payload reports e.g. "Wayland session".
if not super().available:
return False
return bool(self._data.get("available", True))
class ForegroundProcessSensor(_ForegroundEntityBase, SensorEntity):
"""Primary sensor: the process name plus full payload as attributes."""
_attr_icon = "mdi:application"
_attr_translation_key = "foreground_process"
def __init__(
self,
coordinator: ForegroundCoordinator,
entry: ConfigEntry,
) -> None:
super().__init__(coordinator, entry)
self._attr_unique_id = f"{entry.entry_id}_foreground_process"
@property
def native_value(self) -> str | None:
return self._data.get("process_name")
@property
def extra_state_attributes(self) -> dict[str, Any]:
d = self._data
return {
"pid": d.get("pid"),
"executable_path": d.get("executable_path"),
"window_title": d.get("window_title"),
"window_handle": d.get("window_handle"),
"is_fullscreen": d.get("is_fullscreen"),
"is_minimized": d.get("is_minimized"),
"monitor_id": d.get("monitor_id"),
"monitor_geometry": d.get("monitor_geometry"),
"window_geometry": d.get("window_geometry"),
"started_at": d.get("started_at"),
"platform": d.get("platform"),
"is_browser": d.get("is_browser"),
"browser_page_title": d.get("browser_page_title"),
"browser_url": d.get("browser_url"),
"available": d.get("available"),
"error": d.get("error"),
}
class ForegroundWindowTitleSensor(_ForegroundEntityBase, SensorEntity):
_attr_icon = "mdi:window-restore"
_attr_translation_key = "window_title"
def __init__(
self,
coordinator: ForegroundCoordinator,
entry: ConfigEntry,
) -> None:
super().__init__(coordinator, entry)
self._attr_unique_id = f"{entry.entry_id}_foreground_window_title"
@property
def native_value(self) -> str | None:
return self._data.get("window_title")
class ForegroundPidSensor(_ForegroundEntityBase, SensorEntity):
_attr_icon = "mdi:identifier"
_attr_translation_key = "pid"
_attr_entity_registry_enabled_default = False # diagnostic-leaning
def __init__(
self,
coordinator: ForegroundCoordinator,
entry: ConfigEntry,
) -> None:
super().__init__(coordinator, entry)
self._attr_unique_id = f"{entry.entry_id}_foreground_pid"
@property
def native_value(self) -> int | None:
return self._data.get("pid")
class ForegroundMonitorSensor(_ForegroundEntityBase, SensorEntity):
_attr_icon = "mdi:monitor"
_attr_translation_key = "foreground_monitor"
def __init__(
self,
coordinator: ForegroundCoordinator,
entry: ConfigEntry,
) -> None:
super().__init__(coordinator, entry)
self._attr_unique_id = f"{entry.entry_id}_foreground_monitor"
@property
def native_value(self) -> int | None:
return self._data.get("monitor_id")
class ForegroundStartedAtSensor(_ForegroundEntityBase, SensorEntity):
"""Process start time as a timezone-aware datetime."""
_attr_icon = "mdi:clock-start"
_attr_translation_key = "process_started"
_attr_device_class = SensorDeviceClass.TIMESTAMP
_attr_entity_registry_enabled_default = False
def __init__(
self,
coordinator: ForegroundCoordinator,
entry: ConfigEntry,
) -> None:
super().__init__(coordinator, entry)
self._attr_unique_id = f"{entry.entry_id}_foreground_started_at"
@property
def native_value(self) -> datetime | None:
ts = self._data.get("started_at")
if ts is None:
return None
try:
return datetime.fromtimestamp(float(ts), tz=timezone.utc)
except (TypeError, ValueError, OSError):
return None
class ForegroundFullscreenBinarySensor(_ForegroundEntityBase, BinarySensorEntity):
_attr_icon = "mdi:fullscreen"
_attr_translation_key = "fullscreen"
def __init__(
self,
coordinator: ForegroundCoordinator,
entry: ConfigEntry,
) -> None:
super().__init__(coordinator, entry)
self._attr_unique_id = f"{entry.entry_id}_foreground_fullscreen"
@property
def is_on(self) -> bool:
return bool(self._data.get("is_fullscreen"))
class ForegroundMinimizedBinarySensor(_ForegroundEntityBase, BinarySensorEntity):
_attr_icon = "mdi:window-minimize"
_attr_translation_key = "minimized"
_attr_entity_registry_enabled_default = False
def __init__(
self,
coordinator: ForegroundCoordinator,
entry: ConfigEntry,
) -> None:
super().__init__(coordinator, entry)
self._attr_unique_id = f"{entry.entry_id}_foreground_minimized"
@property
def is_on(self) -> bool:
return bool(self._data.get("is_minimized"))
FOREGROUND_SENSORS: tuple[type[_ForegroundEntityBase], ...] = (
ForegroundProcessSensor,
ForegroundWindowTitleSensor,
ForegroundPidSensor,
ForegroundMonitorSensor,
ForegroundStartedAtSensor,
)
FOREGROUND_BINARY_SENSORS: tuple[type[_ForegroundEntityBase], ...] = (
ForegroundFullscreenBinarySensor,
ForegroundMinimizedBinarySensor,
)
@@ -0,0 +1,59 @@
"""Shared coordinator for the foreground (topmost) process snapshot.
The media server already broadcasts the foreground process over the media
WebSocket, but the WS client lives inside the media-player entity. Sensors
need their own polling fallback so they keep working when the user disables
the WebSocket feature in options, or while the WS is reconnecting.
"""
from __future__ import annotations
import logging
from datetime import timedelta
from typing import Any
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .api_client import MediaServerClient, MediaServerError
_LOGGER = logging.getLogger(__name__)
# Foreground polls fairly often — the user-facing value (process name)
# changes whenever the user alt-tabs, so a coarse poll would feel laggy.
# The server side is cached at ~500ms so even a 5s poll stays cheap.
DEFAULT_FOREGROUND_POLL_INTERVAL = 5
class ForegroundCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"""Polls ``/api/foreground`` and fans out to sensor entities."""
def __init__(
self,
hass: HomeAssistant,
client: MediaServerClient,
poll_interval: int = DEFAULT_FOREGROUND_POLL_INTERVAL,
) -> None:
super().__init__(
hass,
_LOGGER,
name="Remote Media Player Foreground",
update_interval=timedelta(seconds=poll_interval),
)
self.client = client
async def _async_update_data(self) -> dict[str, Any]:
try:
return await self.client.get_foreground()
except MediaServerError as err:
raise UpdateFailed(f"Failed to fetch foreground info: {err}") from err
def apply_websocket_snapshot(self, data: dict[str, Any]) -> None:
"""Update from a push event (WebSocket) without an HTTP roundtrip.
Called by the media-player WS receiver when a ``foreground``/
``foreground_update`` frame arrives. Updates ``self.data`` directly
so all listening sensors refresh immediately, and avoids the next
scheduled poll spending bandwidth on the same value.
"""
self.async_set_updated_data(data)
@@ -8,5 +8,5 @@
"integration_type": "device",
"iot_class": "local_push",
"requirements": ["aiohttp>=3.8.0"],
"version": "1.0.0"
"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
@@ -172,6 +184,9 @@ class MediaPlayerCoordinator(DataUpdateCoordinator[dict[str, Any]]):
on_status_update=self._handle_ws_status_update,
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():
@@ -206,6 +221,19 @@ class MediaPlayerCoordinator(DataUpdateCoordinator[dict[str, Any]]):
# Schedule reconnect attempt
self._schedule_reconnect()
@callback
def _handle_ws_foreground_update(self, data: dict[str, Any]) -> None:
"""Forward a foreground WS push into the shared foreground coordinator."""
if not self._entry:
return
try:
store = self.hass.data[DOMAIN][self._entry.entry_id]
except KeyError:
return
coordinator = store.get("foreground_coordinator")
if coordinator is not None:
coordinator.apply_websocket_snapshot(data)
@callback
def _handle_ws_scripts_changed(self) -> None:
"""Handle scripts changed notification from WebSocket."""
+84 -52
View File
@@ -8,11 +8,13 @@ from typing import Any
from homeassistant.components.number import NumberEntity, NumberMode
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .api_client import MediaServerClient, MediaServerError
from .const import DOMAIN
from .display_coordinator import DisplayCoordinator
from .display_device import display_device_info
_LOGGER = logging.getLogger(__name__)
@@ -22,32 +24,28 @@ async def async_setup_entry(
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up display brightness number entities from a config entry."""
client: MediaServerClient = hass.data[DOMAIN][entry.entry_id]["client"]
"""Set up display brightness + contrast number entities from a config entry."""
data = hass.data[DOMAIN][entry.entry_id]
client: MediaServerClient = data["client"]
coordinator: DisplayCoordinator = data["display_coordinator"]
try:
monitors = await client.get_display_monitors()
except MediaServerError as err:
_LOGGER.error("Failed to fetch display monitors: %s", err)
if not coordinator.data:
return
entities = [
DisplayBrightnessNumber(
client=client,
entry=entry,
monitor=monitor,
)
for monitor in monitors
if monitor.get("brightness") is not None
]
entities: list[Any] = []
for monitor in coordinator.data.values():
if monitor.get("brightness") is not None:
entities.append(DisplayBrightnessNumber(coordinator, client, entry, monitor))
if monitor.get("contrast_supported"):
entities.append(DisplayContrastNumber(coordinator, client, entry, monitor))
if entities:
async_add_entities(entities)
_LOGGER.info("Added %d display brightness entities", len(entities))
_LOGGER.info("Added %d display number entities", len(entities))
class DisplayBrightnessNumber(NumberEntity):
"""Number entity for controlling display brightness."""
class _DisplayNumberBase(CoordinatorEntity[DisplayCoordinator], NumberEntity):
"""Shared boilerplate for per-display number entities."""
_attr_has_entity_name = True
_attr_native_min_value = 0
@@ -55,56 +53,90 @@ class DisplayBrightnessNumber(NumberEntity):
_attr_native_step = 1
_attr_native_unit_of_measurement = "%"
_attr_mode = NumberMode.SLIDER
_attr_icon = "mdi:brightness-6"
def __init__(
self,
coordinator: DisplayCoordinator,
client: MediaServerClient,
entry: ConfigEntry,
monitor: dict[str, Any],
) -> None:
"""Initialize the display brightness entity."""
super().__init__(coordinator)
self._client = client
self._entry = entry
self._monitor_id: int = monitor["id"]
self._monitor_name: str = monitor.get("name", f"Monitor {monitor['id']}")
self._resolution: str | None = monitor.get("resolution")
self._attr_native_value = monitor.get("brightness")
# Use resolution in name to disambiguate same-name monitors
display_name = self._monitor_name
if self._resolution:
display_name = f"{self._monitor_name} ({self._resolution})"
self._attr_unique_id = f"{entry.entry_id}_display_brightness_{self._monitor_id}"
self._attr_name = f"Display {display_name} Brightness"
self._attr_device_info = display_device_info(entry, monitor)
@property
def device_info(self) -> DeviceInfo:
"""Return device info."""
return DeviceInfo(
identifiers={(DOMAIN, self._entry.entry_id)},
name=self._entry.title,
manufacturer="Remote Media Player",
model="Media Server",
)
def _monitor(self) -> dict[str, Any]:
if self.coordinator.data is None:
return {}
return self.coordinator.data.get(self._monitor_id, {})
class DisplayBrightnessNumber(_DisplayNumberBase):
"""Number entity for controlling display brightness."""
_attr_translation_key = "brightness"
_attr_icon = "mdi:brightness-6"
def __init__(
self,
coordinator: DisplayCoordinator,
client: MediaServerClient,
entry: ConfigEntry,
monitor: dict[str, Any],
) -> None:
super().__init__(coordinator, client, entry, monitor)
self._attr_unique_id = f"{entry.entry_id}_display_brightness_{self._monitor_id}"
@property
def native_value(self) -> float | None:
value = self._monitor.get("brightness")
return None if value is None else float(value)
async def async_set_native_value(self, value: float) -> None:
"""Set the brightness value."""
try:
await self._client.set_display_brightness(self._monitor_id, int(value))
self._attr_native_value = int(value)
self.async_write_ha_state()
except MediaServerError as err:
_LOGGER.error("Failed to set brightness for monitor %d: %s", self._monitor_id, err)
return
self.coordinator.apply_optimistic(self._monitor_id, brightness=int(value))
async def async_update(self) -> None:
"""Fetch updated brightness from the server."""
class DisplayContrastNumber(_DisplayNumberBase):
"""Number entity for controlling DDC/CI display contrast."""
_attr_translation_key = "contrast"
_attr_icon = "mdi:contrast-circle"
def __init__(
self,
coordinator: DisplayCoordinator,
client: MediaServerClient,
entry: ConfigEntry,
monitor: dict[str, Any],
) -> None:
super().__init__(coordinator, client, entry, monitor)
self._attr_unique_id = f"{entry.entry_id}_display_contrast_{self._monitor_id}"
@property
def native_value(self) -> float | None:
value = self._monitor.get("contrast")
return None if value is None else float(value)
async def async_set_native_value(self, value: float) -> None:
try:
monitors = await self._client.get_display_monitors()
for monitor in monitors:
if monitor["id"] == self._monitor_id:
self._attr_native_value = monitor.get("brightness")
break
result = await self._client.set_display_contrast(self._monitor_id, int(value))
except MediaServerError as err:
_LOGGER.error("Failed to update brightness for monitor %d: %s", self._monitor_id, err)
_LOGGER.error("Failed to set contrast for monitor %d: %s", self._monitor_id, err)
return
if not result.get("success"):
# DDC/CI silently dropped the write — pull authoritative state from
# the server instead of trusting our optimistic value.
_LOGGER.warning(
"Monitor %d rejected contrast %d (DDC/CI silently dropped)",
self._monitor_id, int(value),
)
await self.coordinator.async_request_refresh()
return
self.coordinator.apply_optimistic(self._monitor_id, contrast=int(value))
@@ -0,0 +1,202 @@
"""Select platform: DDC/CI input source, color preset, and picture mode."""
from __future__ import annotations
import logging
from typing import Any
from homeassistant.components.select import SelectEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .api_client import MediaServerClient, MediaServerError
from .const import DOMAIN
from .display_coordinator import DisplayCoordinator
from .display_device import display_device_info
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up per-display select entities."""
data = hass.data[DOMAIN][entry.entry_id]
client: MediaServerClient = data["client"]
coordinator: DisplayCoordinator = data["display_coordinator"]
if not coordinator.data:
return
entities: list[Any] = []
for monitor in coordinator.data.values():
if monitor.get("input_source_supported") and monitor.get("available_input_sources"):
entities.append(DisplayInputSourceSelect(coordinator, client, entry, monitor))
if monitor.get("color_preset_supported") and monitor.get("available_color_presets"):
entities.append(DisplayColorPresetSelect(coordinator, client, entry, monitor))
if monitor.get("picture_mode_supported") and monitor.get("available_picture_modes"):
entities.append(DisplayPictureModeSelect(coordinator, client, entry, monitor))
if entities:
async_add_entities(entities)
_LOGGER.info("Added %d display select entities", len(entities))
class _DisplaySelectBase(CoordinatorEntity[DisplayCoordinator], SelectEntity):
"""Shared base for per-display selects."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: DisplayCoordinator,
client: MediaServerClient,
entry: ConfigEntry,
monitor: dict[str, Any],
) -> None:
super().__init__(coordinator)
self._client = client
self._monitor_id: int = monitor["id"]
self._attr_device_info = display_device_info(entry, monitor)
@property
def _monitor(self) -> dict[str, Any]:
if self.coordinator.data is None:
return {}
return self.coordinator.data.get(self._monitor_id, {})
class DisplayInputSourceSelect(_DisplaySelectBase):
"""Switch the monitor's active input (HDMI1, DP1, ...)."""
_attr_translation_key = "input_source"
_attr_icon = "mdi:video-input-hdmi"
def __init__(
self,
coordinator: DisplayCoordinator,
client: MediaServerClient,
entry: ConfigEntry,
monitor: dict[str, Any],
) -> None:
super().__init__(coordinator, client, entry, monitor)
self._attr_unique_id = f"{entry.entry_id}_display_input_{self._monitor_id}"
# Available inputs are a static EDID/DDC capability — capturing them
# at discovery avoids re-allocating the option list on every poll.
self._attr_options = list(monitor.get("available_input_sources") or [])
@property
def current_option(self) -> str | None:
current = self._monitor.get("input_source")
return current if current in self._attr_options else None
async def async_select_option(self, option: str) -> None:
try:
result = await self._client.set_display_input_source(self._monitor_id, option)
except MediaServerError as err:
_LOGGER.error("Failed to set input source for monitor %d: %s", self._monitor_id, err)
return
if not result.get("success"):
_LOGGER.warning(
"Monitor %d rejected input source %s (DDC/CI silently dropped)",
self._monitor_id, option,
)
await self.coordinator.async_request_refresh()
return
self.coordinator.apply_optimistic(self._monitor_id, input_source=option)
class DisplayColorPresetSelect(_DisplaySelectBase):
"""Switch the monitor's color temperature preset (sRGB / 6500K / ...)."""
_attr_translation_key = "color_preset"
_attr_icon = "mdi:palette"
def __init__(
self,
coordinator: DisplayCoordinator,
client: MediaServerClient,
entry: ConfigEntry,
monitor: dict[str, Any],
) -> None:
super().__init__(coordinator, client, entry, monitor)
self._attr_unique_id = f"{entry.entry_id}_display_color_preset_{self._monitor_id}"
self._attr_options = list(monitor.get("available_color_presets") or [])
@property
def current_option(self) -> str | None:
current = self._monitor.get("color_preset")
return current if current in self._attr_options else None
async def async_select_option(self, option: str) -> None:
try:
result = await self._client.set_display_color_preset(self._monitor_id, option)
except MediaServerError as err:
_LOGGER.error("Failed to set color preset for monitor %d: %s", self._monitor_id, err)
return
if not result.get("success"):
_LOGGER.warning(
"Monitor %d rejected color preset %s (DDC/CI silently dropped)",
self._monitor_id, option,
)
await self.coordinator.async_request_refresh()
return
self.coordinator.apply_optimistic(self._monitor_id, color_preset=option)
class DisplayPictureModeSelect(_DisplaySelectBase):
"""Switch the monitor's picture/scene mode via VCP 0xDC.
The server returns options as ``[{code: int, label: str}, ...]``. Labels
are exposed as user-facing options and a label→code map drives writes.
"""
_attr_translation_key = "picture_mode"
_attr_icon = "mdi:image-multiple"
def __init__(
self,
coordinator: DisplayCoordinator,
client: MediaServerClient,
entry: ConfigEntry,
monitor: dict[str, Any],
) -> None:
super().__init__(coordinator, client, entry, monitor)
self._attr_unique_id = f"{entry.entry_id}_display_picture_mode_{self._monitor_id}"
modes = monitor.get("available_picture_modes") or []
self._label_to_code: dict[str, int] = {
mode["label"]: mode["code"]
for mode in modes
if "label" in mode and "code" in mode
}
self._attr_options = list(self._label_to_code.keys())
@property
def current_option(self) -> str | None:
current = self._monitor.get("picture_mode")
return current if current in self._attr_options else None
async def async_select_option(self, option: str) -> None:
code = self._label_to_code.get(option)
if code is None:
_LOGGER.error("Unknown picture mode label: %s", option)
return
try:
result = await self._client.set_display_picture_mode(self._monitor_id, code)
except MediaServerError as err:
_LOGGER.error("Failed to set picture mode for monitor %d: %s", self._monitor_id, err)
return
if not result.get("success"):
_LOGGER.warning(
"Monitor %d rejected picture mode %s (code %d) - monitor's DDC/CI"
" implementation of VCP 0xDC may be incomplete",
self._monitor_id, option, code,
)
await self.coordinator.async_request_refresh()
return
self.coordinator.apply_optimistic(self._monitor_id, picture_mode=option)
@@ -0,0 +1,78 @@
"""Diagnostic sensors exposed per display (resolution, etc.)."""
from __future__ import annotations
import logging
from typing import Any
from homeassistant.components.sensor import SensorEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import EntityCategory
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .display_coordinator import DisplayCoordinator
from .display_device import display_device_info
from .foreground import FOREGROUND_SENSORS
from .foreground_coordinator import ForegroundCoordinator
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up display + foreground sensor entities."""
store = hass.data[DOMAIN][entry.entry_id]
display_coordinator: DisplayCoordinator = store["display_coordinator"]
foreground_coordinator: ForegroundCoordinator | None = store.get(
"foreground_coordinator"
)
entities: list[Any] = []
if display_coordinator.data:
entities.extend(
DisplayResolutionSensor(display_coordinator, entry, monitor)
for monitor in display_coordinator.data.values()
if monitor.get("resolution")
)
if foreground_coordinator is not None:
entities.extend(
cls(foreground_coordinator, entry) for cls in FOREGROUND_SENSORS
)
if entities:
async_add_entities(entities)
_LOGGER.info("Added %d sensor entities (display + foreground)", len(entities))
class DisplayResolutionSensor(CoordinatorEntity[DisplayCoordinator], SensorEntity):
"""Diagnostic sensor reporting the EDID-derived display resolution."""
_attr_has_entity_name = True
_attr_translation_key = "resolution"
_attr_entity_category = EntityCategory.DIAGNOSTIC
_attr_icon = "mdi:monitor-screenshot"
def __init__(
self,
coordinator: DisplayCoordinator,
entry: ConfigEntry,
monitor: dict[str, Any],
) -> None:
super().__init__(coordinator)
self._monitor_id: int = monitor["id"]
self._attr_unique_id = f"{entry.entry_id}_display_resolution_{self._monitor_id}"
self._attr_device_info = display_device_info(entry, monitor)
@property
def native_value(self) -> str | None:
if self.coordinator.data is None:
return None
return self.coordinator.data.get(self._monitor_id, {}).get("resolution")
@@ -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
@@ -9,10 +16,29 @@ execute_script:
example: "launch_spotify"
selector:
text:
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)
required: false
example: '["arg1", "arg2"]'
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,13 +8,17 @@
"host": "Host",
"port": "Port",
"token": "API Token",
"use_ssl": "Use HTTPS",
"verify_ssl": "Verify TLS certificate",
"name": "Name",
"poll_interval": "Poll Interval"
},
"data_description": {
"host": "Hostname or IP address of the Media Server",
"port": "Port number (default: 8765)",
"token": "API authentication token from the server configuration",
"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)"
}
@@ -42,18 +46,56 @@
}
}
},
"entity": {
"binary_sensor": {
"primary_display": { "name": "Primary display" },
"power_control_supported": { "name": "Power control supported" },
"fullscreen": { "name": "Fullscreen" },
"minimized": { "name": "Minimized" }
},
"sensor": {
"resolution": { "name": "Resolution" },
"foreground_process": { "name": "Foreground process" },
"window_title": { "name": "Window title" },
"pid": { "name": "PID" },
"foreground_monitor": { "name": "Monitor" },
"process_started": { "name": "Process started" }
},
"number": {
"brightness": { "name": "Brightness" },
"contrast": { "name": "Contrast" }
},
"switch": {
"power": { "name": "Power" }
},
"select": {
"input_source": { "name": "Input source" },
"color_preset": { "name": "Color preset" },
"picture_mode": { "name": "Picture mode" }
}
},
"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"
}
}
}
+47 -68
View File
@@ -5,14 +5,16 @@ from __future__ import annotations
import logging
from typing import Any
from homeassistant.components.switch import SwitchEntity, SwitchDeviceClass
from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .api_client import MediaServerClient, MediaServerError
from .const import DOMAIN
from .display_coordinator import DisplayCoordinator
from .display_device import display_device_info
_LOGGER = logging.getLogger(__name__)
@@ -23,21 +25,16 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up display power switch entities from a config entry."""
client: MediaServerClient = hass.data[DOMAIN][entry.entry_id]["client"]
data = hass.data[DOMAIN][entry.entry_id]
client: MediaServerClient = data["client"]
coordinator: DisplayCoordinator = data["display_coordinator"]
try:
monitors = await client.get_display_monitors()
except MediaServerError as err:
_LOGGER.error("Failed to fetch display monitors: %s", err)
if not coordinator.data:
return
entities = [
DisplayPowerSwitch(
client=client,
entry=entry,
monitor=monitor,
)
for monitor in monitors
DisplayPowerSwitch(coordinator, client, entry, monitor)
for monitor in coordinator.data.values()
if monitor.get("power_supported", False)
]
@@ -46,80 +43,62 @@ async def async_setup_entry(
_LOGGER.info("Added %d display power switch entities", len(entities))
class DisplayPowerSwitch(SwitchEntity):
class DisplayPowerSwitch(CoordinatorEntity[DisplayCoordinator], SwitchEntity):
"""Switch entity for controlling display power."""
_attr_has_entity_name = True
_attr_device_class = SwitchDeviceClass.SWITCH
_attr_translation_key = "power"
def __init__(
self,
coordinator: DisplayCoordinator,
client: MediaServerClient,
entry: ConfigEntry,
monitor: dict[str, Any],
) -> None:
"""Initialize the display power switch."""
super().__init__(coordinator)
self._client = client
self._entry = entry
self._monitor_id: int = monitor["id"]
self._monitor_name: str = monitor.get("name", f"Monitor {monitor['id']}")
self._resolution: str | None = monitor.get("resolution")
self._attr_is_on = monitor.get("power_on", True)
# Use resolution in name to disambiguate same-name monitors
display_name = self._monitor_name
if self._resolution:
display_name = f"{self._monitor_name} ({self._resolution})"
self._attr_unique_id = f"{entry.entry_id}_display_power_{self._monitor_id}"
self._attr_name = f"Display {display_name} Power"
self._attr_device_info = display_device_info(entry, monitor)
@property
def _monitor(self) -> dict[str, Any]:
if self.coordinator.data is None:
return {}
return self.coordinator.data.get(self._monitor_id, {})
@property
def is_on(self) -> bool:
return bool(self._monitor.get("power_on", True))
@property
def icon(self) -> str:
"""Return icon based on power state."""
return "mdi:monitor" if self._attr_is_on else "mdi:monitor-off"
return "mdi:monitor" if self.is_on else "mdi:monitor-off"
@property
def device_info(self) -> DeviceInfo:
"""Return device info."""
return DeviceInfo(
identifiers={(DOMAIN, self._entry.entry_id)},
name=self._entry.title,
manufacturer="Remote Media Player",
model="Media Server",
)
async def _set_power(self, on: bool) -> None:
try:
result = await self._client.set_display_power(self._monitor_id, on)
except MediaServerError as err:
_LOGGER.error(
"Failed to %s monitor %d: %s",
"turn on" if on else "turn off",
self._monitor_id,
err,
)
return
if not result.get("success"):
_LOGGER.error(
"Failed to %s monitor %d",
"turn on" if on else "turn off",
self._monitor_id,
)
return
self.coordinator.apply_optimistic(self._monitor_id, power_on=on)
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the monitor on."""
try:
result = await self._client.set_display_power(self._monitor_id, True)
if result.get("success"):
self._attr_is_on = True
self.async_write_ha_state()
else:
_LOGGER.error("Failed to turn on monitor %d", self._monitor_id)
except MediaServerError as err:
_LOGGER.error("Failed to turn on monitor %d: %s", self._monitor_id, err)
await self._set_power(True)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the monitor off."""
try:
result = await self._client.set_display_power(self._monitor_id, False)
if result.get("success"):
self._attr_is_on = False
self.async_write_ha_state()
else:
_LOGGER.error("Failed to turn off monitor %d", self._monitor_id)
except MediaServerError as err:
_LOGGER.error("Failed to turn off monitor %d: %s", self._monitor_id, err)
async def async_update(self) -> None:
"""Fetch updated power state from the server."""
try:
monitors = await self._client.get_display_monitors()
for monitor in monitors:
if monitor["id"] == self._monitor_id:
self._attr_is_on = monitor.get("power_on", True)
break
except MediaServerError as err:
_LOGGER.error("Failed to update power state for monitor %d: %s", self._monitor_id, err)
await self._set_power(False)
@@ -8,13 +8,17 @@
"host": "Host",
"port": "Port",
"token": "API Token",
"use_ssl": "Use HTTPS",
"verify_ssl": "Verify TLS certificate",
"name": "Name",
"poll_interval": "Poll Interval"
},
"data_description": {
"host": "Hostname or IP address of the Media Server",
"port": "Port number (default: 8765)",
"token": "API authentication token from the server configuration",
"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)"
}
@@ -42,18 +46,56 @@
}
}
},
"entity": {
"binary_sensor": {
"primary_display": { "name": "Primary display" },
"power_control_supported": { "name": "Power control supported" },
"fullscreen": { "name": "Fullscreen" },
"minimized": { "name": "Minimized" }
},
"sensor": {
"resolution": { "name": "Resolution" },
"foreground_process": { "name": "Foreground process" },
"window_title": { "name": "Window title" },
"pid": { "name": "PID" },
"foreground_monitor": { "name": "Monitor" },
"process_started": { "name": "Process started" }
},
"number": {
"brightness": { "name": "Brightness" },
"contrast": { "name": "Contrast" }
},
"switch": {
"power": { "name": "Power" }
},
"select": {
"input_source": { "name": "Input source" },
"color_preset": { "name": "Color preset" },
"picture_mode": { "name": "Picture mode" }
}
},
"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,13 +8,17 @@
"host": "Хост",
"port": "Порт",
"token": "API токен",
"use_ssl": "Использовать HTTPS",
"verify_ssl": "Проверять TLS-сертификат",
"name": "Название",
"poll_interval": "Интервал опроса"
},
"data_description": {
"host": "Имя хоста или IP-адрес Media Server",
"port": "Номер порта (по умолчанию: 8765)",
"token": "Токен аутентификации из конфигурации сервера",
"token": "Токен аутентификации из конфигурации сервера. Оставьте пустым, если сервер работает без аутентификации.",
"use_ssl": "Подключаться к серверу по HTTPS/WSS. Сервер должен быть настроен с параметрами ssl_certfile и ssl_keyfile в config.yaml.",
"verify_ssl": "Проверять цепочку TLS-сертификатов сервера. Отключайте только если сервер использует самоподписанный сертификат в доверенной локальной сети.",
"name": "Отображаемое имя медиаплеера",
"poll_interval": "Частота опроса статуса (в секундах)"
}
@@ -42,18 +46,56 @@
}
}
},
"entity": {
"binary_sensor": {
"primary_display": { "name": "Основной дисплей" },
"power_control_supported": { "name": "Поддержка управления питанием" },
"fullscreen": { "name": "Полноэкранный режим" },
"minimized": { "name": "Свёрнуто" }
},
"sensor": {
"resolution": { "name": "Разрешение" },
"foreground_process": { "name": "Активный процесс" },
"window_title": { "name": "Заголовок окна" },
"pid": { "name": "PID" },
"foreground_monitor": { "name": "Монитор" },
"process_started": { "name": "Запуск процесса" }
},
"number": {
"brightness": { "name": "Яркость" },
"contrast": { "name": "Контрастность" }
},
"switch": {
"power": { "name": "Питание" }
},
"select": {
"input_source": { "name": "Источник сигнала" },
"color_preset": { "name": "Цветовая температура" },
"picture_mode": { "name": "Режим изображения" }
}
},
"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": "Абсолютный путь к медиафайлу на целевом хабе"
}
}
}