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:
2026-05-17 23:46:26 +03:00
parent 68e338de4e
commit ab0585278c
14 changed files with 313 additions and 252 deletions
@@ -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"
} }
+56 -59
View File
@@ -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)
+50 -67
View File
@@ -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)
+16 -23
View File
@@ -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)"
} }
+44 -48
View File
@@ -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": "Частота опроса статуса (в секундах)"
} }