Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b92b69b0e8 | |||
| 9d277276b8 | |||
| ab0585278c |
+14
-16
@@ -1,22 +1,19 @@
|
||||
## v0.3.0 (2026-05-15)
|
||||
|
||||
### Migration / Behavior Changes
|
||||
- Each physical monitor is now its own Home Assistant device, linked to the media-server hub via `via_device`. Existing brightness and power entities migrate to per-display devices automatically on first reload — `unique_id`s are preserved, but entities will move under new devices in the UI and can be placed in their own area/room ([4156ded](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/4156ded))
|
||||
- The hub keeps the `media_player` and script buttons; per-display devices hold the power switch, brightness slider, and the new DDC/CI capability entities ([4156ded](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/4156ded))
|
||||
## v0.3.1 (2026-05-18)
|
||||
|
||||
### Features
|
||||
- **New diagnostic sensors**: `DisplayResolutionSensor` exposes the active resolution parsed from EDID ([4156ded](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/4156ded))
|
||||
- **New diagnostic binary sensors**: `DisplayPrimaryBinarySensor` and `DisplayPowerControlBinarySensor` make it visible why a power switch is or isn't created for a given display ([4156ded](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/4156ded))
|
||||
- **New select entities**:
|
||||
- `DisplayInputSourceSelect` — switch active input (HDMI1, DP1, etc.) via DDC/CI
|
||||
- `DisplayColorPresetSelect` — color temperature presets
|
||||
- `DisplayPictureModeSelect` — VCP 0xDC scene modes ([4156ded](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/4156ded))
|
||||
- **New number entity**: `DisplayContrastNumber` exposed alongside the existing brightness slider ([4156ded](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/4156ded))
|
||||
- Per-display devices now show real manufacturer/model pulled from EDID; device names no longer prepend the hub title (the hierarchy is already shown via `via_device`) ([4156ded](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/4156ded))
|
||||
- Select and number entities verify the server's `success` flag and re-sync from the actual monitor state when a write is silently rejected — some monitors honor DDC/CI reads but ignore writes for certain codes ([4156ded](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/4156ded))
|
||||
- **Foreground process sensors** — new "Foreground" device (linked to the hub via `via_device`) exposing what's currently in focus on the remote PC:
|
||||
- `sensor.foreground_process` — process name as state, with full payload (PID, exec path, window title, fullscreen flag, monitor, geometry, browser detection, browser page title/URL, error) as attributes
|
||||
- `sensor.window_title`, `sensor.pid`, `sensor.foreground_monitor`, `sensor.process_started` (TIMESTAMP device class)
|
||||
- `binary_sensor.fullscreen`, `binary_sensor.minimized`
|
||||
- Fed by a new `ForegroundCoordinator` polling `GET /api/foreground` every 5s, with near-real-time updates via the existing WebSocket (`foreground` / `foreground_update` push frames flow into the coordinator) ([9d27727](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/9d27727))
|
||||
- **Optional API token** — `CONF_TOKEN` is now optional. When the media server runs without `api_tokens` configured (auth disabled), the integration omits the `Authorization` header and `?token=` query from REST, WebSocket, and album-art URLs. Server-side auth rejections still surface as `invalid_auth` in the UI ([ab05852](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/ab05852))
|
||||
|
||||
### Performance
|
||||
- `api_client` no longer forces `?refresh=true` on every poll, letting the integration ride the media server's TTL cache instead of triggering a full DDC/CI probe per entity update ([4156ded](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/4156ded))
|
||||
- **Shared `DisplayCoordinator`** — a single `/api/display/monitors` poll per cycle is now fanned out to all per-display entities (binary sensors, numbers, selects, sensors, switches) via `CoordinatorEntity`. Removes ~9x redundant requests per polling cycle that previously came from each entity calling `get_display_monitors()` in its own `async_update` ([ab05852](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/ab05852))
|
||||
- **Optimistic write-through** — `coordinator.apply_optimistic(...)` keeps sibling entities in sync after slider/select writes without an extra network round-trip ([ab05852](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/ab05852))
|
||||
|
||||
### UI / Localization
|
||||
- Display entities migrated to Home Assistant **translation keys** (`strings.json` / `translations/*`), so per-language UI text flows through the standard locale mechanism instead of hardcoded English strings ([9d27727](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/9d27727))
|
||||
|
||||
---
|
||||
|
||||
@@ -25,6 +22,7 @@
|
||||
|
||||
| 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 |
|
||||
| [9d27727](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/9d27727) | feat(foreground): foreground process sensors + translation key migration | alexei.dolgolyov |
|
||||
| [ab05852](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/ab05852) | feat: shared DisplayCoordinator + optional API token | alexei.dolgolyov |
|
||||
|
||||
</details>
|
||||
|
||||
@@ -24,6 +24,8 @@ from .const import (
|
||||
SERVICE_EXECUTE_SCRIPT,
|
||||
SERVICE_PLAY_MEDIA_FILE,
|
||||
)
|
||||
from .display_coordinator import DisplayCoordinator
|
||||
from .foreground_coordinator import ForegroundCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -78,10 +80,29 @@ 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
|
||||
|
||||
@@ -31,6 +31,7 @@ from .const import (
|
||||
API_BROWSER_BROWSE,
|
||||
API_BROWSER_PLAY,
|
||||
API_DISPLAY_MONITORS,
|
||||
API_FOREGROUND,
|
||||
API_DISPLAY_BRIGHTNESS,
|
||||
API_DISPLAY_POWER,
|
||||
API_DISPLAY_CONTRAST,
|
||||
@@ -92,11 +93,16 @@ class MediaServerClient:
|
||||
await self._session.close()
|
||||
|
||||
def _get_headers(self) -> dict[str, str]:
|
||||
"""Get headers for API requests."""
|
||||
return {
|
||||
"Authorization": f"Bearer {self._token}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
"""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.
|
||||
"""
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if self._token:
|
||||
headers["Authorization"] = f"Bearer {self._token}"
|
||||
return headers
|
||||
|
||||
async def _request(
|
||||
self,
|
||||
@@ -178,13 +184,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
|
||||
|
||||
@@ -411,6 +421,15 @@ class MediaServerClient:
|
||||
"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."""
|
||||
@@ -423,6 +442,7 @@ 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,
|
||||
) -> None:
|
||||
"""Initialize the WebSocket client.
|
||||
|
||||
@@ -433,6 +453,7 @@ 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
|
||||
"""
|
||||
self._host = host
|
||||
self._port = int(port)
|
||||
@@ -440,7 +461,12 @@ 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
|
||||
# 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.
|
||||
token_query = f"?token={token}" if token else ""
|
||||
self._ws_url = f"ws://{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
|
||||
@@ -514,15 +540,19 @@ 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 ""
|
||||
status_data["album_art_url"] = (
|
||||
f"http://{self._host}:{self._port}"
|
||||
f"{status_data['album_art_url']}?token={self._token}&t={track_hash}"
|
||||
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")
|
||||
|
||||
|
||||
@@ -10,10 +10,13 @@ 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 .api_client import MediaServerClient, MediaServerError
|
||||
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__)
|
||||
|
||||
@@ -23,26 +26,38 @@ async def async_setup_entry(
|
||||
entry: ConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up per-display binary sensor entities."""
|
||||
client: MediaServerClient = hass.data[DOMAIN][entry.entry_id]["client"]
|
||||
|
||||
try:
|
||||
monitors = await client.get_display_monitors()
|
||||
except MediaServerError as err:
|
||||
_LOGGER.error("Failed to fetch display monitors: %s", err)
|
||||
return
|
||||
"""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] = []
|
||||
for monitor in monitors:
|
||||
entities.append(DisplayPrimaryBinarySensor(client, entry, monitor))
|
||||
entities.append(DisplayPowerControlBinarySensor(client, entry, monitor))
|
||||
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 display binary sensor entities", len(entities))
|
||||
_LOGGER.info(
|
||||
"Added %d binary sensor entities (display + foreground)", len(entities)
|
||||
)
|
||||
|
||||
|
||||
class _DisplayBinarySensorBase(BinarySensorEntity):
|
||||
class _DisplayBinarySensorBase(
|
||||
CoordinatorEntity[DisplayCoordinator], BinarySensorEntity
|
||||
):
|
||||
"""Common boilerplate for per-display diagnostic binary sensors."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
@@ -50,67 +65,58 @@ class _DisplayBinarySensorBase(BinarySensorEntity):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
client: MediaServerClient,
|
||||
coordinator: DisplayCoordinator,
|
||||
entry: ConfigEntry,
|
||||
monitor: dict[str, Any],
|
||||
) -> None:
|
||||
self._client = client
|
||||
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_name = "Primary display"
|
||||
_attr_translation_key = "primary_display"
|
||||
_attr_icon = "mdi:monitor-star"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
client: MediaServerClient,
|
||||
coordinator: DisplayCoordinator,
|
||||
entry: ConfigEntry,
|
||||
monitor: dict[str, Any],
|
||||
) -> None:
|
||||
super().__init__(client, entry, monitor)
|
||||
super().__init__(coordinator, entry, monitor)
|
||||
self._attr_unique_id = f"{entry.entry_id}_display_primary_{self._monitor_id}"
|
||||
self._attr_is_on = bool(monitor.get("is_primary"))
|
||||
|
||||
async def async_update(self) -> None:
|
||||
try:
|
||||
monitors = await self._client.get_display_monitors()
|
||||
for monitor in monitors:
|
||||
if monitor["id"] == self._monitor_id:
|
||||
self._attr_is_on = bool(monitor.get("is_primary"))
|
||||
break
|
||||
except MediaServerError as err:
|
||||
_LOGGER.error("Failed to refresh primary flag for monitor %d: %s", self._monitor_id, err)
|
||||
@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_name = "Power control supported"
|
||||
_attr_translation_key = "power_control_supported"
|
||||
_attr_icon = "mdi:power-plug"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
client: MediaServerClient,
|
||||
coordinator: DisplayCoordinator,
|
||||
entry: ConfigEntry,
|
||||
monitor: dict[str, Any],
|
||||
) -> None:
|
||||
super().__init__(client, entry, monitor)
|
||||
self._attr_unique_id = f"{entry.entry_id}_display_power_supported_{self._monitor_id}"
|
||||
self._attr_is_on = bool(monitor.get("power_supported"))
|
||||
super().__init__(coordinator, entry, monitor)
|
||||
self._attr_unique_id = (
|
||||
f"{entry.entry_id}_display_power_supported_{self._monitor_id}"
|
||||
)
|
||||
|
||||
async def async_update(self) -> None:
|
||||
try:
|
||||
monitors = await self._client.get_display_monitors()
|
||||
for monitor in monitors:
|
||||
if monitor["id"] == self._monitor_id:
|
||||
self._attr_is_on = bool(monitor.get("power_supported"))
|
||||
break
|
||||
except MediaServerError as err:
|
||||
_LOGGER.error(
|
||||
"Failed to refresh power_supported flag for monitor %d: %s",
|
||||
self._monitor_id, err,
|
||||
)
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
return bool(self._monitor.get("power_supported"))
|
||||
|
||||
@@ -44,10 +44,14 @@ 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 "",
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -125,7 +129,7 @@ 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
|
||||
)
|
||||
|
||||
@@ -16,6 +16,10 @@ DEFAULT_POLL_INTERVAL = 5
|
||||
DEFAULT_NAME = "Remote Media Player"
|
||||
DEFAULT_USE_WEBSOCKET = True
|
||||
DEFAULT_RECONNECT_INTERVAL = 5
|
||||
# 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,6 +41,7 @@ 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"
|
||||
|
||||
@@ -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,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": "0.3.0"
|
||||
"version": "0.3.1"
|
||||
}
|
||||
|
||||
@@ -172,6 +172,7 @@ 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,
|
||||
)
|
||||
|
||||
if await self._ws_client.connect():
|
||||
@@ -206,6 +207,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."""
|
||||
|
||||
@@ -9,9 +9,11 @@ from homeassistant.components.number import NumberEntity, NumberMode
|
||||
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__)
|
||||
@@ -23,123 +25,118 @@ async def async_setup_entry(
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up display brightness + contrast number 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: list[Any] = []
|
||||
for monitor in monitors:
|
||||
for monitor in coordinator.data.values():
|
||||
if monitor.get("brightness") is not None:
|
||||
entities.append(DisplayBrightnessNumber(client, entry, monitor))
|
||||
entities.append(DisplayBrightnessNumber(coordinator, client, entry, monitor))
|
||||
if monitor.get("contrast_supported"):
|
||||
entities.append(DisplayContrastNumber(client, entry, monitor))
|
||||
entities.append(DisplayContrastNumber(coordinator, client, entry, monitor))
|
||||
|
||||
if entities:
|
||||
async_add_entities(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_name = "Brightness"
|
||||
_attr_native_min_value = 0
|
||||
_attr_native_max_value = 100
|
||||
_attr_native_step = 1
|
||||
_attr_native_unit_of_measurement = "%"
|
||||
_attr_mode = NumberMode.SLIDER
|
||||
|
||||
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 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:
|
||||
"""Initialize the display brightness entity."""
|
||||
self._client = client
|
||||
self._entry = entry
|
||||
self._monitor_id: int = monitor["id"]
|
||||
self._attr_native_value = monitor.get("brightness")
|
||||
super().__init__(coordinator, client, entry, monitor)
|
||||
self._attr_unique_id = f"{entry.entry_id}_display_brightness_{self._monitor_id}"
|
||||
self._attr_device_info = display_device_info(entry, monitor)
|
||||
|
||||
@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)
|
||||
|
||||
async def async_update(self) -> None:
|
||||
"""Fetch updated brightness from the server."""
|
||||
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
|
||||
except MediaServerError as err:
|
||||
_LOGGER.error("Failed to update brightness for monitor %d: %s", self._monitor_id, err)
|
||||
return
|
||||
self.coordinator.apply_optimistic(self._monitor_id, brightness=int(value))
|
||||
|
||||
|
||||
class DisplayContrastNumber(NumberEntity):
|
||||
class DisplayContrastNumber(_DisplayNumberBase):
|
||||
"""Number entity for controlling DDC/CI display contrast."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_name = "Contrast"
|
||||
_attr_native_min_value = 0
|
||||
_attr_native_max_value = 100
|
||||
_attr_native_step = 1
|
||||
_attr_native_unit_of_measurement = "%"
|
||||
_attr_mode = NumberMode.SLIDER
|
||||
_attr_translation_key = "contrast"
|
||||
_attr_icon = "mdi:contrast-circle"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: DisplayCoordinator,
|
||||
client: MediaServerClient,
|
||||
entry: ConfigEntry,
|
||||
monitor: dict[str, Any],
|
||||
) -> None:
|
||||
"""Initialize the display contrast entity."""
|
||||
self._client = client
|
||||
self._monitor_id: int = monitor["id"]
|
||||
self._attr_native_value = monitor.get("contrast")
|
||||
super().__init__(coordinator, client, entry, monitor)
|
||||
self._attr_unique_id = f"{entry.entry_id}_display_contrast_{self._monitor_id}"
|
||||
self._attr_device_info = display_device_info(entry, monitor)
|
||||
|
||||
@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:
|
||||
"""Set the contrast value."""
|
||||
try:
|
||||
result = await self._client.set_display_contrast(self._monitor_id, int(value))
|
||||
except MediaServerError as 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.async_update()
|
||||
self.async_write_ha_state()
|
||||
await self.coordinator.async_request_refresh()
|
||||
return
|
||||
self._attr_native_value = int(value)
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_update(self) -> None:
|
||||
"""Fetch updated contrast from the server."""
|
||||
try:
|
||||
monitors = await self._client.get_display_monitors()
|
||||
for monitor in monitors:
|
||||
if monitor["id"] == self._monitor_id:
|
||||
self._attr_native_value = monitor.get("contrast")
|
||||
break
|
||||
except MediaServerError as err:
|
||||
_LOGGER.error("Failed to update contrast for monitor %d: %s", self._monitor_id, err)
|
||||
self.coordinator.apply_optimistic(self._monitor_id, contrast=int(value))
|
||||
|
||||
@@ -9,9 +9,11 @@ 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__)
|
||||
@@ -23,61 +25,74 @@ async def async_setup_entry(
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up per-display select entities."""
|
||||
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: list[Any] = []
|
||||
for monitor in monitors:
|
||||
for monitor in coordinator.data.values():
|
||||
if monitor.get("input_source_supported") and monitor.get("available_input_sources"):
|
||||
entities.append(DisplayInputSourceSelect(client, entry, monitor))
|
||||
entities.append(DisplayInputSourceSelect(coordinator, client, entry, monitor))
|
||||
if monitor.get("color_preset_supported") and monitor.get("available_color_presets"):
|
||||
entities.append(DisplayColorPresetSelect(client, entry, monitor))
|
||||
entities.append(DisplayColorPresetSelect(coordinator, client, entry, monitor))
|
||||
if monitor.get("picture_mode_supported") and monitor.get("available_picture_modes"):
|
||||
entities.append(DisplayPictureModeSelect(client, entry, monitor))
|
||||
entities.append(DisplayPictureModeSelect(coordinator, client, entry, monitor))
|
||||
|
||||
if entities:
|
||||
async_add_entities(entities)
|
||||
_LOGGER.info("Added %d display select entities", len(entities))
|
||||
|
||||
|
||||
class _DisplaySelectBase(SelectEntity):
|
||||
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_name = "Input source"
|
||||
_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__(client, entry, monitor)
|
||||
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 [])
|
||||
current = monitor.get("input_source")
|
||||
self._attr_current_option = current if current in self._attr_options else None
|
||||
|
||||
@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:
|
||||
@@ -90,42 +105,32 @@ class DisplayInputSourceSelect(_DisplaySelectBase):
|
||||
"Monitor %d rejected input source %s (DDC/CI silently dropped)",
|
||||
self._monitor_id, option,
|
||||
)
|
||||
# Re-read so the entity state reflects what the monitor actually did.
|
||||
await self.async_update()
|
||||
self.async_write_ha_state()
|
||||
await self.coordinator.async_request_refresh()
|
||||
return
|
||||
self._attr_current_option = option
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_update(self) -> None:
|
||||
try:
|
||||
monitors = await self._client.get_display_monitors()
|
||||
for monitor in monitors:
|
||||
if monitor["id"] == self._monitor_id:
|
||||
current = monitor.get("input_source")
|
||||
self._attr_current_option = current if current in self._attr_options else None
|
||||
break
|
||||
except MediaServerError as err:
|
||||
_LOGGER.error("Failed to refresh input source for monitor %d: %s", self._monitor_id, err)
|
||||
self.coordinator.apply_optimistic(self._monitor_id, input_source=option)
|
||||
|
||||
|
||||
class DisplayColorPresetSelect(_DisplaySelectBase):
|
||||
"""Switch the monitor's color temperature preset (sRGB / 6500K / ...)."""
|
||||
|
||||
_attr_name = "Color preset"
|
||||
_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__(client, entry, monitor)
|
||||
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 [])
|
||||
current = monitor.get("color_preset")
|
||||
self._attr_current_option = current if current in self._attr_options else None
|
||||
|
||||
@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:
|
||||
@@ -138,41 +143,29 @@ class DisplayColorPresetSelect(_DisplaySelectBase):
|
||||
"Monitor %d rejected color preset %s (DDC/CI silently dropped)",
|
||||
self._monitor_id, option,
|
||||
)
|
||||
await self.async_update()
|
||||
self.async_write_ha_state()
|
||||
await self.coordinator.async_request_refresh()
|
||||
return
|
||||
self._attr_current_option = option
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_update(self) -> None:
|
||||
try:
|
||||
monitors = await self._client.get_display_monitors()
|
||||
for monitor in monitors:
|
||||
if monitor["id"] == self._monitor_id:
|
||||
current = monitor.get("color_preset")
|
||||
self._attr_current_option = current if current in self._attr_options else None
|
||||
break
|
||||
except MediaServerError as err:
|
||||
_LOGGER.error("Failed to refresh color preset for monitor %d: %s", self._monitor_id, err)
|
||||
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}, ...]`. We use
|
||||
labels as the user-facing options and keep a label→code map for writes.
|
||||
The server returns options as ``[{code: int, label: str}, ...]``. Labels
|
||||
are exposed as user-facing options and a label→code map drives writes.
|
||||
"""
|
||||
|
||||
_attr_name = "Picture mode"
|
||||
_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__(client, entry, monitor)
|
||||
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 []
|
||||
@@ -182,8 +175,11 @@ class DisplayPictureModeSelect(_DisplaySelectBase):
|
||||
if "label" in mode and "code" in mode
|
||||
}
|
||||
self._attr_options = list(self._label_to_code.keys())
|
||||
current = monitor.get("picture_mode")
|
||||
self._attr_current_option = current if current in self._attr_options else None
|
||||
|
||||
@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)
|
||||
@@ -201,19 +197,6 @@ class DisplayPictureModeSelect(_DisplaySelectBase):
|
||||
" implementation of VCP 0xDC may be incomplete",
|
||||
self._monitor_id, option, code,
|
||||
)
|
||||
await self.async_update()
|
||||
self.async_write_ha_state()
|
||||
await self.coordinator.async_request_refresh()
|
||||
return
|
||||
self._attr_current_option = option
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_update(self) -> None:
|
||||
try:
|
||||
monitors = await self._client.get_display_monitors()
|
||||
for monitor in monitors:
|
||||
if monitor["id"] == self._monitor_id:
|
||||
current = monitor.get("picture_mode")
|
||||
self._attr_current_option = current if current in self._attr_options else None
|
||||
break
|
||||
except MediaServerError as err:
|
||||
_LOGGER.error("Failed to refresh picture mode for monitor %d: %s", self._monitor_id, err)
|
||||
self.coordinator.apply_optimistic(self._monitor_id, picture_mode=option)
|
||||
|
||||
@@ -10,10 +10,13 @@ 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 .api_client import MediaServerClient, MediaServerError
|
||||
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__)
|
||||
|
||||
@@ -23,54 +26,53 @@ async def async_setup_entry(
|
||||
entry: ConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up per-display sensor entities."""
|
||||
client: MediaServerClient = hass.data[DOMAIN][entry.entry_id]["client"]
|
||||
"""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"
|
||||
)
|
||||
|
||||
try:
|
||||
monitors = await client.get_display_monitors()
|
||||
except MediaServerError as err:
|
||||
_LOGGER.error("Failed to fetch display monitors: %s", err)
|
||||
return
|
||||
entities: list[Any] = []
|
||||
|
||||
entities = [
|
||||
DisplayResolutionSensor(client, entry, monitor)
|
||||
for monitor in monitors
|
||||
if monitor.get("resolution")
|
||||
]
|
||||
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 display sensor entities", len(entities))
|
||||
_LOGGER.info("Added %d sensor entities (display + foreground)", len(entities))
|
||||
|
||||
|
||||
class DisplayResolutionSensor(SensorEntity):
|
||||
class DisplayResolutionSensor(CoordinatorEntity[DisplayCoordinator], SensorEntity):
|
||||
"""Diagnostic sensor reporting the EDID-derived display resolution."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_name = "Resolution"
|
||||
_attr_translation_key = "resolution"
|
||||
_attr_entity_category = EntityCategory.DIAGNOSTIC
|
||||
_attr_icon = "mdi:monitor-screenshot"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
client: MediaServerClient,
|
||||
coordinator: DisplayCoordinator,
|
||||
entry: ConfigEntry,
|
||||
monitor: dict[str, Any],
|
||||
) -> None:
|
||||
"""Initialize the display resolution sensor."""
|
||||
self._client = client
|
||||
super().__init__(coordinator)
|
||||
self._monitor_id: int = monitor["id"]
|
||||
self._attr_native_value = monitor.get("resolution")
|
||||
self._attr_unique_id = f"{entry.entry_id}_display_resolution_{self._monitor_id}"
|
||||
self._attr_device_info = display_device_info(entry, monitor)
|
||||
|
||||
async def async_update(self) -> None:
|
||||
"""Refresh resolution from the server (rarely changes)."""
|
||||
try:
|
||||
monitors = await self._client.get_display_monitors()
|
||||
for monitor in monitors:
|
||||
if monitor["id"] == self._monitor_id:
|
||||
self._attr_native_value = monitor.get("resolution")
|
||||
break
|
||||
except MediaServerError as err:
|
||||
_LOGGER.error("Failed to refresh resolution for monitor %d: %s", self._monitor_id, err)
|
||||
@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")
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"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.",
|
||||
"name": "Display name for this media player",
|
||||
"poll_interval": "How often to poll for status updates (seconds)"
|
||||
}
|
||||
@@ -42,6 +42,34 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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",
|
||||
|
||||
@@ -9,9 +9,11 @@ from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity
|
||||
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__)
|
||||
@@ -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,63 +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_name = "Power"
|
||||
_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._attr_is_on = monitor.get("power_on", True)
|
||||
self._attr_unique_id = f"{entry.entry_id}_display_power_{self._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, {})
|
||||
|
||||
@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"
|
||||
|
||||
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)
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"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.",
|
||||
"name": "Display name for this media player",
|
||||
"poll_interval": "How often to poll for status updates (seconds)"
|
||||
}
|
||||
@@ -42,6 +42,34 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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",
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"data_description": {
|
||||
"host": "Имя хоста или IP-адрес Media Server",
|
||||
"port": "Номер порта (по умолчанию: 8765)",
|
||||
"token": "Токен аутентификации из конфигурации сервера",
|
||||
"token": "Токен аутентификации из конфигурации сервера. Оставьте пустым, если сервер работает без аутентификации.",
|
||||
"name": "Отображаемое имя медиаплеера",
|
||||
"poll_interval": "Частота опроса статуса (в секундах)"
|
||||
}
|
||||
@@ -42,6 +42,34 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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": "Выполнить скрипт",
|
||||
|
||||
Reference in New Issue
Block a user