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>
This commit is contained in:
@@ -24,6 +24,7 @@ from .const import (
|
|||||||
SERVICE_EXECUTE_SCRIPT,
|
SERVICE_EXECUTE_SCRIPT,
|
||||||
SERVICE_PLAY_MEDIA_FILE,
|
SERVICE_PLAY_MEDIA_FILE,
|
||||||
)
|
)
|
||||||
|
from .display_coordinator import DisplayCoordinator
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -78,10 +79,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
await client.close()
|
await client.close()
|
||||||
return False
|
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)
|
||||||
|
|
||||||
# Store client in hass.data
|
# Store client in hass.data
|
||||||
hass.data.setdefault(DOMAIN, {})
|
hass.data.setdefault(DOMAIN, {})
|
||||||
hass.data[DOMAIN][entry.entry_id] = {
|
hass.data[DOMAIN][entry.entry_id] = {
|
||||||
"client": client,
|
"client": client,
|
||||||
|
"display_coordinator": display_coordinator,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Register services if not already registered
|
# Register services if not already registered
|
||||||
|
|||||||
@@ -92,11 +92,16 @@ class MediaServerClient:
|
|||||||
await self._session.close()
|
await self._session.close()
|
||||||
|
|
||||||
def _get_headers(self) -> dict[str, str]:
|
def _get_headers(self) -> dict[str, str]:
|
||||||
"""Get headers for API requests."""
|
"""Get headers for API requests.
|
||||||
return {
|
|
||||||
"Authorization": f"Bearer {self._token}",
|
When no token is configured the media server runs in anonymous mode
|
||||||
"Content-Type": "application/json",
|
(``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(
|
async def _request(
|
||||||
self,
|
self,
|
||||||
@@ -178,13 +183,17 @@ class MediaServerClient:
|
|||||||
"""
|
"""
|
||||||
data = await self._request("GET", API_STATUS)
|
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("/"):
|
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
|
# Add track info hash to force HA to re-fetch when track changes
|
||||||
import hashlib
|
import hashlib
|
||||||
track_id = f"{data.get('title', '')}-{data.get('artist', '')}"
|
track_id = f"{data.get('title', '')}-{data.get('artist', '')}"
|
||||||
track_hash = hashlib.md5(track_id.encode()).hexdigest()[:8]
|
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
|
return data
|
||||||
|
|
||||||
@@ -440,7 +449,11 @@ class MediaServerWebSocket:
|
|||||||
self._on_status_update = on_status_update
|
self._on_status_update = on_status_update
|
||||||
self._on_disconnect = on_disconnect
|
self._on_disconnect = on_disconnect
|
||||||
self._on_scripts_changed = on_scripts_changed
|
self._on_scripts_changed = on_scripts_changed
|
||||||
self._ws_url = f"ws://{host}:{self._port}/api/media/ws?token={token}"
|
# 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._session: aiohttp.ClientSession | None = None
|
||||||
self._ws: aiohttp.ClientWebSocketResponse | None = None
|
self._ws: aiohttp.ClientWebSocketResponse | None = None
|
||||||
self._receive_task: asyncio.Task | None = None
|
self._receive_task: asyncio.Task | None = None
|
||||||
@@ -514,9 +527,10 @@ class MediaServerWebSocket:
|
|||||||
):
|
):
|
||||||
track_id = f"{status_data.get('title', '')}-{status_data.get('artist', '')}"
|
track_id = f"{status_data.get('title', '')}-{status_data.get('artist', '')}"
|
||||||
track_hash = hashlib.md5(track_id.encode()).hexdigest()[:8]
|
track_hash = hashlib.md5(track_id.encode()).hexdigest()[:8]
|
||||||
|
token_param = f"token={self._token}&" if self._token else ""
|
||||||
status_data["album_art_url"] = (
|
status_data["album_art_url"] = (
|
||||||
f"http://{self._host}:{self._port}"
|
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)
|
self._on_status_update(status_data)
|
||||||
elif msg_type == "scripts_changed":
|
elif msg_type == "scripts_changed":
|
||||||
|
|||||||
@@ -10,9 +10,10 @@ from homeassistant.config_entries import ConfigEntry
|
|||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers.entity import EntityCategory
|
from homeassistant.helpers.entity import EntityCategory
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
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 .const import DOMAIN
|
||||||
|
from .display_coordinator import DisplayCoordinator
|
||||||
from .display_device import display_device_info
|
from .display_device import display_device_info
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
@@ -24,25 +25,26 @@ async def async_setup_entry(
|
|||||||
async_add_entities: AddEntitiesCallback,
|
async_add_entities: AddEntitiesCallback,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Set up per-display binary sensor entities."""
|
"""Set up per-display binary sensor entities."""
|
||||||
client: MediaServerClient = hass.data[DOMAIN][entry.entry_id]["client"]
|
coordinator: DisplayCoordinator = hass.data[DOMAIN][entry.entry_id][
|
||||||
|
"display_coordinator"
|
||||||
|
]
|
||||||
|
|
||||||
try:
|
if not coordinator.data:
|
||||||
monitors = await client.get_display_monitors()
|
|
||||||
except MediaServerError as err:
|
|
||||||
_LOGGER.error("Failed to fetch display monitors: %s", err)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
entities: list[Any] = []
|
entities: list[Any] = []
|
||||||
for monitor in monitors:
|
for monitor in coordinator.data.values():
|
||||||
entities.append(DisplayPrimaryBinarySensor(client, entry, monitor))
|
entities.append(DisplayPrimaryBinarySensor(coordinator, entry, monitor))
|
||||||
entities.append(DisplayPowerControlBinarySensor(client, entry, monitor))
|
entities.append(DisplayPowerControlBinarySensor(coordinator, entry, monitor))
|
||||||
|
|
||||||
if entities:
|
if entities:
|
||||||
async_add_entities(entities)
|
async_add_entities(entities)
|
||||||
_LOGGER.info("Added %d display binary sensor entities", len(entities))
|
_LOGGER.info("Added %d display binary sensor entities", len(entities))
|
||||||
|
|
||||||
|
|
||||||
class _DisplayBinarySensorBase(BinarySensorEntity):
|
class _DisplayBinarySensorBase(
|
||||||
|
CoordinatorEntity[DisplayCoordinator], BinarySensorEntity
|
||||||
|
):
|
||||||
"""Common boilerplate for per-display diagnostic binary sensors."""
|
"""Common boilerplate for per-display diagnostic binary sensors."""
|
||||||
|
|
||||||
_attr_has_entity_name = True
|
_attr_has_entity_name = True
|
||||||
@@ -50,14 +52,20 @@ class _DisplayBinarySensorBase(BinarySensorEntity):
|
|||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
client: MediaServerClient,
|
coordinator: DisplayCoordinator,
|
||||||
entry: ConfigEntry,
|
entry: ConfigEntry,
|
||||||
monitor: dict[str, Any],
|
monitor: dict[str, Any],
|
||||||
) -> None:
|
) -> None:
|
||||||
self._client = client
|
super().__init__(coordinator)
|
||||||
self._monitor_id: int = monitor["id"]
|
self._monitor_id: int = monitor["id"]
|
||||||
self._attr_device_info = display_device_info(entry, monitor)
|
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):
|
class DisplayPrimaryBinarySensor(_DisplayBinarySensorBase):
|
||||||
"""Indicates whether the display is the OS primary monitor."""
|
"""Indicates whether the display is the OS primary monitor."""
|
||||||
@@ -67,23 +75,16 @@ class DisplayPrimaryBinarySensor(_DisplayBinarySensorBase):
|
|||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
client: MediaServerClient,
|
coordinator: DisplayCoordinator,
|
||||||
entry: ConfigEntry,
|
entry: ConfigEntry,
|
||||||
monitor: dict[str, Any],
|
monitor: dict[str, Any],
|
||||||
) -> None:
|
) -> 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_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:
|
@property
|
||||||
try:
|
def is_on(self) -> bool:
|
||||||
monitors = await self._client.get_display_monitors()
|
return bool(self._monitor.get("is_primary"))
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
class DisplayPowerControlBinarySensor(_DisplayBinarySensorBase):
|
class DisplayPowerControlBinarySensor(_DisplayBinarySensorBase):
|
||||||
@@ -94,23 +95,15 @@ class DisplayPowerControlBinarySensor(_DisplayBinarySensorBase):
|
|||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
client: MediaServerClient,
|
coordinator: DisplayCoordinator,
|
||||||
entry: ConfigEntry,
|
entry: ConfigEntry,
|
||||||
monitor: dict[str, Any],
|
monitor: dict[str, Any],
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__(client, entry, monitor)
|
super().__init__(coordinator, entry, monitor)
|
||||||
self._attr_unique_id = f"{entry.entry_id}_display_power_supported_{self._monitor_id}"
|
self._attr_unique_id = (
|
||||||
self._attr_is_on = bool(monitor.get("power_supported"))
|
f"{entry.entry_id}_display_power_supported_{self._monitor_id}"
|
||||||
|
)
|
||||||
|
|
||||||
async def async_update(self) -> None:
|
@property
|
||||||
try:
|
def is_on(self) -> bool:
|
||||||
monitors = await self._client.get_display_monitors()
|
return bool(self._monitor.get("power_supported"))
|
||||||
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,
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -44,10 +44,14 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str,
|
|||||||
CannotConnect: If connection fails
|
CannotConnect: If connection fails
|
||||||
InvalidAuth: If authentication 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(
|
client = MediaServerClient(
|
||||||
host=data[CONF_HOST],
|
host=data[CONF_HOST],
|
||||||
port=data[CONF_PORT],
|
port=data[CONF_PORT],
|
||||||
token=data[CONF_TOKEN],
|
token=data.get(CONF_TOKEN, "") or "",
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -125,7 +129,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
mode=selector.NumberSelectorMode.BOX,
|
mode=selector.NumberSelectorMode.BOX,
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
vol.Required(CONF_TOKEN): selector.TextSelector(
|
vol.Optional(CONF_TOKEN, default=""): selector.TextSelector(
|
||||||
selector.TextSelectorConfig(
|
selector.TextSelectorConfig(
|
||||||
type=selector.TextSelectorType.PASSWORD
|
type=selector.TextSelectorType.PASSWORD
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ DEFAULT_POLL_INTERVAL = 5
|
|||||||
DEFAULT_NAME = "Remote Media Player"
|
DEFAULT_NAME = "Remote Media Player"
|
||||||
DEFAULT_USE_WEBSOCKET = True
|
DEFAULT_USE_WEBSOCKET = True
|
||||||
DEFAULT_RECONNECT_INTERVAL = 5
|
DEFAULT_RECONNECT_INTERVAL = 5
|
||||||
|
# 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 endpoints
|
||||||
API_HEALTH = "/api/health"
|
API_HEALTH = "/api/health"
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -8,5 +8,5 @@
|
|||||||
"integration_type": "device",
|
"integration_type": "device",
|
||||||
"iot_class": "local_push",
|
"iot_class": "local_push",
|
||||||
"requirements": ["aiohttp>=3.8.0"],
|
"requirements": ["aiohttp>=3.8.0"],
|
||||||
"version": "0.3.0"
|
"version": "0.3.2"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,9 +9,11 @@ from homeassistant.components.number import NumberEntity, NumberMode
|
|||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
|
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||||
|
|
||||||
from .api_client import MediaServerClient, MediaServerError
|
from .api_client import MediaServerClient, MediaServerError
|
||||||
from .const import DOMAIN
|
from .const import DOMAIN
|
||||||
|
from .display_coordinator import DisplayCoordinator
|
||||||
from .display_device import display_device_info
|
from .display_device import display_device_info
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
@@ -23,123 +25,118 @@ async def async_setup_entry(
|
|||||||
async_add_entities: AddEntitiesCallback,
|
async_add_entities: AddEntitiesCallback,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Set up display brightness + contrast number entities from a config entry."""
|
"""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:
|
if not coordinator.data:
|
||||||
monitors = await client.get_display_monitors()
|
|
||||||
except MediaServerError as err:
|
|
||||||
_LOGGER.error("Failed to fetch display monitors: %s", err)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
entities: list[Any] = []
|
entities: list[Any] = []
|
||||||
for monitor in monitors:
|
for monitor in coordinator.data.values():
|
||||||
if monitor.get("brightness") is not None:
|
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"):
|
if monitor.get("contrast_supported"):
|
||||||
entities.append(DisplayContrastNumber(client, entry, monitor))
|
entities.append(DisplayContrastNumber(coordinator, client, entry, monitor))
|
||||||
|
|
||||||
if entities:
|
if entities:
|
||||||
async_add_entities(entities)
|
async_add_entities(entities)
|
||||||
_LOGGER.info("Added %d display number entities", len(entities))
|
_LOGGER.info("Added %d display number entities", len(entities))
|
||||||
|
|
||||||
|
|
||||||
class DisplayBrightnessNumber(NumberEntity):
|
class _DisplayNumberBase(CoordinatorEntity[DisplayCoordinator], NumberEntity):
|
||||||
"""Number entity for controlling display brightness."""
|
"""Shared boilerplate for per-display number entities."""
|
||||||
|
|
||||||
_attr_has_entity_name = True
|
_attr_has_entity_name = True
|
||||||
_attr_name = "Brightness"
|
|
||||||
_attr_native_min_value = 0
|
_attr_native_min_value = 0
|
||||||
_attr_native_max_value = 100
|
_attr_native_max_value = 100
|
||||||
_attr_native_step = 1
|
_attr_native_step = 1
|
||||||
_attr_native_unit_of_measurement = "%"
|
_attr_native_unit_of_measurement = "%"
|
||||||
_attr_mode = NumberMode.SLIDER
|
_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_name = "Brightness"
|
||||||
_attr_icon = "mdi:brightness-6"
|
_attr_icon = "mdi:brightness-6"
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
|
coordinator: DisplayCoordinator,
|
||||||
client: MediaServerClient,
|
client: MediaServerClient,
|
||||||
entry: ConfigEntry,
|
entry: ConfigEntry,
|
||||||
monitor: dict[str, Any],
|
monitor: dict[str, Any],
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Initialize the display brightness entity."""
|
super().__init__(coordinator, client, entry, monitor)
|
||||||
self._client = client
|
|
||||||
self._entry = entry
|
|
||||||
self._monitor_id: int = monitor["id"]
|
|
||||||
self._attr_native_value = monitor.get("brightness")
|
|
||||||
self._attr_unique_id = f"{entry.entry_id}_display_brightness_{self._monitor_id}"
|
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:
|
async def async_set_native_value(self, value: float) -> None:
|
||||||
"""Set the brightness value."""
|
|
||||||
try:
|
try:
|
||||||
await self._client.set_display_brightness(self._monitor_id, int(value))
|
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:
|
except MediaServerError as err:
|
||||||
_LOGGER.error("Failed to set brightness for monitor %d: %s", self._monitor_id, err)
|
_LOGGER.error("Failed to set brightness for monitor %d: %s", self._monitor_id, err)
|
||||||
|
return
|
||||||
async def async_update(self) -> None:
|
self.coordinator.apply_optimistic(self._monitor_id, brightness=int(value))
|
||||||
"""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)
|
|
||||||
|
|
||||||
|
|
||||||
class DisplayContrastNumber(NumberEntity):
|
class DisplayContrastNumber(_DisplayNumberBase):
|
||||||
"""Number entity for controlling DDC/CI display contrast."""
|
"""Number entity for controlling DDC/CI display contrast."""
|
||||||
|
|
||||||
_attr_has_entity_name = True
|
|
||||||
_attr_name = "Contrast"
|
_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_icon = "mdi:contrast-circle"
|
_attr_icon = "mdi:contrast-circle"
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
|
coordinator: DisplayCoordinator,
|
||||||
client: MediaServerClient,
|
client: MediaServerClient,
|
||||||
entry: ConfigEntry,
|
entry: ConfigEntry,
|
||||||
monitor: dict[str, Any],
|
monitor: dict[str, Any],
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Initialize the display contrast entity."""
|
super().__init__(coordinator, client, entry, monitor)
|
||||||
self._client = client
|
|
||||||
self._monitor_id: int = monitor["id"]
|
|
||||||
self._attr_native_value = monitor.get("contrast")
|
|
||||||
self._attr_unique_id = f"{entry.entry_id}_display_contrast_{self._monitor_id}"
|
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:
|
async def async_set_native_value(self, value: float) -> None:
|
||||||
"""Set the contrast value."""
|
|
||||||
try:
|
try:
|
||||||
result = await self._client.set_display_contrast(self._monitor_id, int(value))
|
result = await self._client.set_display_contrast(self._monitor_id, int(value))
|
||||||
except MediaServerError as err:
|
except MediaServerError as err:
|
||||||
_LOGGER.error("Failed to set contrast for monitor %d: %s", self._monitor_id, err)
|
_LOGGER.error("Failed to set contrast for monitor %d: %s", self._monitor_id, err)
|
||||||
return
|
return
|
||||||
if not result.get("success"):
|
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(
|
_LOGGER.warning(
|
||||||
"Monitor %d rejected contrast %d (DDC/CI silently dropped)",
|
"Monitor %d rejected contrast %d (DDC/CI silently dropped)",
|
||||||
self._monitor_id, int(value),
|
self._monitor_id, int(value),
|
||||||
)
|
)
|
||||||
await self.async_update()
|
await self.coordinator.async_request_refresh()
|
||||||
self.async_write_ha_state()
|
|
||||||
return
|
return
|
||||||
self._attr_native_value = int(value)
|
self.coordinator.apply_optimistic(self._monitor_id, contrast=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)
|
|
||||||
|
|||||||
@@ -9,9 +9,11 @@ from homeassistant.components.select import SelectEntity
|
|||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
|
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||||
|
|
||||||
from .api_client import MediaServerClient, MediaServerError
|
from .api_client import MediaServerClient, MediaServerError
|
||||||
from .const import DOMAIN
|
from .const import DOMAIN
|
||||||
|
from .display_coordinator import DisplayCoordinator
|
||||||
from .display_device import display_device_info
|
from .display_device import display_device_info
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
@@ -23,43 +25,50 @@ async def async_setup_entry(
|
|||||||
async_add_entities: AddEntitiesCallback,
|
async_add_entities: AddEntitiesCallback,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Set up per-display select entities."""
|
"""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:
|
if not coordinator.data:
|
||||||
monitors = await client.get_display_monitors()
|
|
||||||
except MediaServerError as err:
|
|
||||||
_LOGGER.error("Failed to fetch display monitors: %s", err)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
entities: list[Any] = []
|
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"):
|
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"):
|
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"):
|
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:
|
if entities:
|
||||||
async_add_entities(entities)
|
async_add_entities(entities)
|
||||||
_LOGGER.info("Added %d display select entities", len(entities))
|
_LOGGER.info("Added %d display select entities", len(entities))
|
||||||
|
|
||||||
|
|
||||||
class _DisplaySelectBase(SelectEntity):
|
class _DisplaySelectBase(CoordinatorEntity[DisplayCoordinator], SelectEntity):
|
||||||
"""Shared base for per-display selects."""
|
"""Shared base for per-display selects."""
|
||||||
|
|
||||||
_attr_has_entity_name = True
|
_attr_has_entity_name = True
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
|
coordinator: DisplayCoordinator,
|
||||||
client: MediaServerClient,
|
client: MediaServerClient,
|
||||||
entry: ConfigEntry,
|
entry: ConfigEntry,
|
||||||
monitor: dict[str, Any],
|
monitor: dict[str, Any],
|
||||||
) -> None:
|
) -> None:
|
||||||
|
super().__init__(coordinator)
|
||||||
self._client = client
|
self._client = client
|
||||||
self._monitor_id: int = monitor["id"]
|
self._monitor_id: int = monitor["id"]
|
||||||
self._attr_device_info = display_device_info(entry, monitor)
|
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):
|
class DisplayInputSourceSelect(_DisplaySelectBase):
|
||||||
"""Switch the monitor's active input (HDMI1, DP1, ...)."""
|
"""Switch the monitor's active input (HDMI1, DP1, ...)."""
|
||||||
@@ -69,15 +78,21 @@ class DisplayInputSourceSelect(_DisplaySelectBase):
|
|||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
|
coordinator: DisplayCoordinator,
|
||||||
client: MediaServerClient,
|
client: MediaServerClient,
|
||||||
entry: ConfigEntry,
|
entry: ConfigEntry,
|
||||||
monitor: dict[str, Any],
|
monitor: dict[str, Any],
|
||||||
) -> None:
|
) -> 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}"
|
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 [])
|
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:
|
async def async_select_option(self, option: str) -> None:
|
||||||
try:
|
try:
|
||||||
@@ -90,23 +105,9 @@ class DisplayInputSourceSelect(_DisplaySelectBase):
|
|||||||
"Monitor %d rejected input source %s (DDC/CI silently dropped)",
|
"Monitor %d rejected input source %s (DDC/CI silently dropped)",
|
||||||
self._monitor_id, option,
|
self._monitor_id, option,
|
||||||
)
|
)
|
||||||
# Re-read so the entity state reflects what the monitor actually did.
|
await self.coordinator.async_request_refresh()
|
||||||
await self.async_update()
|
|
||||||
self.async_write_ha_state()
|
|
||||||
return
|
return
|
||||||
self._attr_current_option = option
|
self.coordinator.apply_optimistic(self._monitor_id, input_source=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)
|
|
||||||
|
|
||||||
|
|
||||||
class DisplayColorPresetSelect(_DisplaySelectBase):
|
class DisplayColorPresetSelect(_DisplaySelectBase):
|
||||||
@@ -117,15 +118,19 @@ class DisplayColorPresetSelect(_DisplaySelectBase):
|
|||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
|
coordinator: DisplayCoordinator,
|
||||||
client: MediaServerClient,
|
client: MediaServerClient,
|
||||||
entry: ConfigEntry,
|
entry: ConfigEntry,
|
||||||
monitor: dict[str, Any],
|
monitor: dict[str, Any],
|
||||||
) -> None:
|
) -> 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_unique_id = f"{entry.entry_id}_display_color_preset_{self._monitor_id}"
|
||||||
self._attr_options = list(monitor.get("available_color_presets") or [])
|
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:
|
async def async_select_option(self, option: str) -> None:
|
||||||
try:
|
try:
|
||||||
@@ -138,29 +143,16 @@ class DisplayColorPresetSelect(_DisplaySelectBase):
|
|||||||
"Monitor %d rejected color preset %s (DDC/CI silently dropped)",
|
"Monitor %d rejected color preset %s (DDC/CI silently dropped)",
|
||||||
self._monitor_id, option,
|
self._monitor_id, option,
|
||||||
)
|
)
|
||||||
await self.async_update()
|
await self.coordinator.async_request_refresh()
|
||||||
self.async_write_ha_state()
|
|
||||||
return
|
return
|
||||||
self._attr_current_option = option
|
self.coordinator.apply_optimistic(self._monitor_id, color_preset=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)
|
|
||||||
|
|
||||||
|
|
||||||
class DisplayPictureModeSelect(_DisplaySelectBase):
|
class DisplayPictureModeSelect(_DisplaySelectBase):
|
||||||
"""Switch the monitor's picture/scene mode via VCP 0xDC.
|
"""Switch the monitor's picture/scene mode via VCP 0xDC.
|
||||||
|
|
||||||
The server returns options as `[{code: int, label: str}, ...]`. We use
|
The server returns options as ``[{code: int, label: str}, ...]``. Labels
|
||||||
labels as the user-facing options and keep a label→code map for writes.
|
are exposed as user-facing options and a label→code map drives writes.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
_attr_name = "Picture mode"
|
_attr_name = "Picture mode"
|
||||||
@@ -168,11 +160,12 @@ class DisplayPictureModeSelect(_DisplaySelectBase):
|
|||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
|
coordinator: DisplayCoordinator,
|
||||||
client: MediaServerClient,
|
client: MediaServerClient,
|
||||||
entry: ConfigEntry,
|
entry: ConfigEntry,
|
||||||
monitor: dict[str, Any],
|
monitor: dict[str, Any],
|
||||||
) -> None:
|
) -> 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}"
|
self._attr_unique_id = f"{entry.entry_id}_display_picture_mode_{self._monitor_id}"
|
||||||
|
|
||||||
modes = monitor.get("available_picture_modes") or []
|
modes = monitor.get("available_picture_modes") or []
|
||||||
@@ -182,8 +175,11 @@ class DisplayPictureModeSelect(_DisplaySelectBase):
|
|||||||
if "label" in mode and "code" in mode
|
if "label" in mode and "code" in mode
|
||||||
}
|
}
|
||||||
self._attr_options = list(self._label_to_code.keys())
|
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:
|
async def async_select_option(self, option: str) -> None:
|
||||||
code = self._label_to_code.get(option)
|
code = self._label_to_code.get(option)
|
||||||
@@ -201,19 +197,6 @@ class DisplayPictureModeSelect(_DisplaySelectBase):
|
|||||||
" implementation of VCP 0xDC may be incomplete",
|
" implementation of VCP 0xDC may be incomplete",
|
||||||
self._monitor_id, option, code,
|
self._monitor_id, option, code,
|
||||||
)
|
)
|
||||||
await self.async_update()
|
await self.coordinator.async_request_refresh()
|
||||||
self.async_write_ha_state()
|
|
||||||
return
|
return
|
||||||
self._attr_current_option = option
|
self.coordinator.apply_optimistic(self._monitor_id, picture_mode=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)
|
|
||||||
|
|||||||
@@ -10,9 +10,10 @@ from homeassistant.config_entries import ConfigEntry
|
|||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers.entity import EntityCategory
|
from homeassistant.helpers.entity import EntityCategory
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
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 .const import DOMAIN
|
||||||
|
from .display_coordinator import DisplayCoordinator
|
||||||
from .display_device import display_device_info
|
from .display_device import display_device_info
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
@@ -24,17 +25,16 @@ async def async_setup_entry(
|
|||||||
async_add_entities: AddEntitiesCallback,
|
async_add_entities: AddEntitiesCallback,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Set up per-display sensor entities."""
|
"""Set up per-display sensor entities."""
|
||||||
client: MediaServerClient = hass.data[DOMAIN][entry.entry_id]["client"]
|
coordinator: DisplayCoordinator = hass.data[DOMAIN][entry.entry_id][
|
||||||
|
"display_coordinator"
|
||||||
|
]
|
||||||
|
|
||||||
try:
|
if not coordinator.data:
|
||||||
monitors = await client.get_display_monitors()
|
|
||||||
except MediaServerError as err:
|
|
||||||
_LOGGER.error("Failed to fetch display monitors: %s", err)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
entities = [
|
entities = [
|
||||||
DisplayResolutionSensor(client, entry, monitor)
|
DisplayResolutionSensor(coordinator, entry, monitor)
|
||||||
for monitor in monitors
|
for monitor in coordinator.data.values()
|
||||||
if monitor.get("resolution")
|
if monitor.get("resolution")
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -43,7 +43,7 @@ async def async_setup_entry(
|
|||||||
_LOGGER.info("Added %d display sensor entities", len(entities))
|
_LOGGER.info("Added %d display sensor entities", len(entities))
|
||||||
|
|
||||||
|
|
||||||
class DisplayResolutionSensor(SensorEntity):
|
class DisplayResolutionSensor(CoordinatorEntity[DisplayCoordinator], SensorEntity):
|
||||||
"""Diagnostic sensor reporting the EDID-derived display resolution."""
|
"""Diagnostic sensor reporting the EDID-derived display resolution."""
|
||||||
|
|
||||||
_attr_has_entity_name = True
|
_attr_has_entity_name = True
|
||||||
@@ -53,24 +53,17 @@ class DisplayResolutionSensor(SensorEntity):
|
|||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
client: MediaServerClient,
|
coordinator: DisplayCoordinator,
|
||||||
entry: ConfigEntry,
|
entry: ConfigEntry,
|
||||||
monitor: dict[str, Any],
|
monitor: dict[str, Any],
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Initialize the display resolution sensor."""
|
super().__init__(coordinator)
|
||||||
self._client = client
|
|
||||||
self._monitor_id: int = monitor["id"]
|
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_unique_id = f"{entry.entry_id}_display_resolution_{self._monitor_id}"
|
||||||
self._attr_device_info = display_device_info(entry, monitor)
|
self._attr_device_info = display_device_info(entry, monitor)
|
||||||
|
|
||||||
async def async_update(self) -> None:
|
@property
|
||||||
"""Refresh resolution from the server (rarely changes)."""
|
def native_value(self) -> str | None:
|
||||||
try:
|
if self.coordinator.data is None:
|
||||||
monitors = await self._client.get_display_monitors()
|
return None
|
||||||
for monitor in monitors:
|
return self.coordinator.data.get(self._monitor_id, {}).get("resolution")
|
||||||
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)
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
"data_description": {
|
"data_description": {
|
||||||
"host": "Hostname or IP address of the Media Server",
|
"host": "Hostname or IP address of the Media Server",
|
||||||
"port": "Port number (default: 8765)",
|
"port": "Port number (default: 8765)",
|
||||||
"token": "API authentication token from the server configuration",
|
"token": "API authentication token from the server configuration. Leave blank if the server runs without authentication.",
|
||||||
"name": "Display name for this media player",
|
"name": "Display name for this media player",
|
||||||
"poll_interval": "How often to poll for status updates (seconds)"
|
"poll_interval": "How often to poll for status updates (seconds)"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,9 +9,11 @@ from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity
|
|||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
|
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||||
|
|
||||||
from .api_client import MediaServerClient, MediaServerError
|
from .api_client import MediaServerClient, MediaServerError
|
||||||
from .const import DOMAIN
|
from .const import DOMAIN
|
||||||
|
from .display_coordinator import DisplayCoordinator
|
||||||
from .display_device import display_device_info
|
from .display_device import display_device_info
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
@@ -23,21 +25,16 @@ async def async_setup_entry(
|
|||||||
async_add_entities: AddEntitiesCallback,
|
async_add_entities: AddEntitiesCallback,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Set up display power switch entities from a config entry."""
|
"""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:
|
if not coordinator.data:
|
||||||
monitors = await client.get_display_monitors()
|
|
||||||
except MediaServerError as err:
|
|
||||||
_LOGGER.error("Failed to fetch display monitors: %s", err)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
entities = [
|
entities = [
|
||||||
DisplayPowerSwitch(
|
DisplayPowerSwitch(coordinator, client, entry, monitor)
|
||||||
client=client,
|
for monitor in coordinator.data.values()
|
||||||
entry=entry,
|
|
||||||
monitor=monitor,
|
|
||||||
)
|
|
||||||
for monitor in monitors
|
|
||||||
if monitor.get("power_supported", False)
|
if monitor.get("power_supported", False)
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -46,7 +43,7 @@ async def async_setup_entry(
|
|||||||
_LOGGER.info("Added %d display power switch entities", len(entities))
|
_LOGGER.info("Added %d display power switch entities", len(entities))
|
||||||
|
|
||||||
|
|
||||||
class DisplayPowerSwitch(SwitchEntity):
|
class DisplayPowerSwitch(CoordinatorEntity[DisplayCoordinator], SwitchEntity):
|
||||||
"""Switch entity for controlling display power."""
|
"""Switch entity for controlling display power."""
|
||||||
|
|
||||||
_attr_has_entity_name = True
|
_attr_has_entity_name = True
|
||||||
@@ -55,54 +52,53 @@ class DisplayPowerSwitch(SwitchEntity):
|
|||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
|
coordinator: DisplayCoordinator,
|
||||||
client: MediaServerClient,
|
client: MediaServerClient,
|
||||||
entry: ConfigEntry,
|
entry: ConfigEntry,
|
||||||
monitor: dict[str, Any],
|
monitor: dict[str, Any],
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Initialize the display power switch."""
|
super().__init__(coordinator)
|
||||||
self._client = client
|
self._client = client
|
||||||
self._entry = entry
|
|
||||||
self._monitor_id: int = monitor["id"]
|
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_unique_id = f"{entry.entry_id}_display_power_{self._monitor_id}"
|
||||||
self._attr_device_info = display_device_info(entry, monitor)
|
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
|
@property
|
||||||
def icon(self) -> str:
|
def icon(self) -> str:
|
||||||
"""Return icon based on power state."""
|
return "mdi:monitor" if self.is_on else "mdi:monitor-off"
|
||||||
return "mdi:monitor" if self._attr_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:
|
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||||
"""Turn the monitor on."""
|
await self._set_power(True)
|
||||||
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)
|
|
||||||
|
|
||||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||||
"""Turn the monitor off."""
|
await self._set_power(False)
|
||||||
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)
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
"data_description": {
|
"data_description": {
|
||||||
"host": "Hostname or IP address of the Media Server",
|
"host": "Hostname or IP address of the Media Server",
|
||||||
"port": "Port number (default: 8765)",
|
"port": "Port number (default: 8765)",
|
||||||
"token": "API authentication token from the server configuration",
|
"token": "API authentication token from the server configuration. Leave blank if the server runs without authentication.",
|
||||||
"name": "Display name for this media player",
|
"name": "Display name for this media player",
|
||||||
"poll_interval": "How often to poll for status updates (seconds)"
|
"poll_interval": "How often to poll for status updates (seconds)"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
"data_description": {
|
"data_description": {
|
||||||
"host": "Имя хоста или IP-адрес Media Server",
|
"host": "Имя хоста или IP-адрес Media Server",
|
||||||
"port": "Номер порта (по умолчанию: 8765)",
|
"port": "Номер порта (по умолчанию: 8765)",
|
||||||
"token": "Токен аутентификации из конфигурации сервера",
|
"token": "Токен аутентификации из конфигурации сервера. Оставьте пустым, если сервер работает без аутентификации.",
|
||||||
"name": "Отображаемое имя медиаплеера",
|
"name": "Отображаемое имя медиаплеера",
|
||||||
"poll_interval": "Частота опроса статуса (в секундах)"
|
"poll_interval": "Частота опроса статуса (в секундах)"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user