2 Commits

Author SHA1 Message Date
alexei.dolgolyov 68e338de4e chore: release v0.3.0
Release / release (push) Successful in 3s
2026-05-15 14:52:48 +03:00
alexei.dolgolyov 4156dedf5e feat(displays): per-display devices + DDC/CI capability entities
Restructure how displays are exposed in Home Assistant:

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

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

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

Bumps manifest.json to 0.3.0 - the device topology change is user-visible
and existing brightness/power entities migrate to per-display devices on
first reload (unique_ids are preserved).
2026-05-15 14:46:50 +03:00
11 changed files with 600 additions and 63 deletions
+17 -5
View File
@@ -1,10 +1,22 @@
## v0.1.1 (2026-03-26)
## v0.3.0 (2026-05-15)
### ⚠️ Breaking Changes
- Replace positional script `args` (list) with typed named `params` (dict) — update any automations calling `remote_media_player.execute_script` to use the new `params` format ([de4b7cf](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/de4b7cf))
### Migration / Behavior Changes
- Each physical monitor is now its own Home Assistant device, linked to the media-server hub via `via_device`. Existing brightness and power entities migrate to per-display devices automatically on first reload — `unique_id`s are preserved, but entities will move under new devices in the UI and can be placed in their own area/room ([4156ded](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/4156ded))
- The hub keeps the `media_player` and script buttons; per-display devices hold the power switch, brightness slider, and the new DDC/CI capability entities ([4156ded](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/4156ded))
### Features
- Add execute_script service documentation to README ([de4b7cf](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/de4b7cf))
- **New diagnostic sensors**: `DisplayResolutionSensor` exposes the active resolution parsed from EDID ([4156ded](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/4156ded))
- **New diagnostic binary sensors**: `DisplayPrimaryBinarySensor` and `DisplayPowerControlBinarySensor` make it visible why a power switch is or isn't created for a given display ([4156ded](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/4156ded))
- **New select entities**:
- `DisplayInputSourceSelect` — switch active input (HDMI1, DP1, etc.) via DDC/CI
- `DisplayColorPresetSelect` — color temperature presets
- `DisplayPictureModeSelect` — VCP 0xDC scene modes ([4156ded](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/4156ded))
- **New number entity**: `DisplayContrastNumber` exposed alongside the existing brightness slider ([4156ded](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/4156ded))
- Per-display devices now show real manufacturer/model pulled from EDID; device names no longer prepend the hub title (the hierarchy is already shown via `via_device`) ([4156ded](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/4156ded))
- Select and number entities verify the server's `success` flag and re-sync from the actual monitor state when a write is silently rejected — some monitors honor DDC/CI reads but ignore writes for certain codes ([4156ded](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/4156ded))
### Performance
- `api_client` no longer forces `?refresh=true` on every poll, letting the integration ride the media server's TTL cache instead of triggering a full DDC/CI probe per entity update ([4156ded](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/4156ded))
---
@@ -13,6 +25,6 @@
| Hash | Message | Author |
|------|---------|--------|
| [de4b7cf](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/de4b7cf) | feat: replace script args with typed named parameters | alexei.dolgolyov |
| [4156ded](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/4156ded) | feat(displays): per-display devices + DDC/CI capability entities | alexei.dolgolyov |
</details>
@@ -27,7 +27,15 @@ from .const import (
_LOGGER = logging.getLogger(__name__)
PLATFORMS: list[Platform] = [Platform.MEDIA_PLAYER, Platform.BUTTON, Platform.NUMBER, Platform.SWITCH]
PLATFORMS: list[Platform] = [
Platform.MEDIA_PLAYER,
Platform.BUTTON,
Platform.NUMBER,
Platform.SWITCH,
Platform.SENSOR,
Platform.BINARY_SENSOR,
Platform.SELECT,
]
# Service schema for execute_script
SERVICE_EXECUTE_SCRIPT_SCHEMA = vol.Schema(
@@ -33,6 +33,10 @@ from .const import (
API_DISPLAY_MONITORS,
API_DISPLAY_BRIGHTNESS,
API_DISPLAY_POWER,
API_DISPLAY_CONTRAST,
API_DISPLAY_INPUT_SOURCE,
API_DISPLAY_COLOR_PRESET,
API_DISPLAY_PICTURE_MODE,
)
_LOGGER = logging.getLogger(__name__)
@@ -348,12 +352,12 @@ class MediaServerClient:
return await self._request("POST", API_BROWSER_PLAY, {"path": file_path})
async def get_display_monitors(self) -> list[dict[str, Any]]:
"""Get list of connected monitors with brightness and power info.
"""Get list of connected monitors with brightness, power, DDC/CI state.
Returns:
List of monitor dicts with id, name, brightness, power_supported, power_on, resolution
Uses the server's short TTL cache so per-entity polling does not pay
the full DDC/CI probe cost on every call.
"""
return await self._request("GET", f"{API_DISPLAY_MONITORS}?refresh=true")
return await self._request("GET", API_DISPLAY_MONITORS)
async def set_display_brightness(self, monitor_id: int, brightness: int) -> dict[str, Any]:
"""Set brightness for a specific monitor.
@@ -383,6 +387,30 @@ class MediaServerClient:
"POST", f"{API_DISPLAY_POWER}/{monitor_id}", {"on": on}
)
async def set_display_contrast(self, monitor_id: int, contrast: int) -> dict[str, Any]:
"""Set DDC/CI contrast for a specific monitor (0-100)."""
return await self._request(
"POST", f"{API_DISPLAY_CONTRAST}/{monitor_id}", {"contrast": contrast}
)
async def set_display_input_source(self, monitor_id: int, source: str) -> dict[str, Any]:
"""Switch a monitor's DDC/CI input source by enum name (e.g. 'HDMI1')."""
return await self._request(
"POST", f"{API_DISPLAY_INPUT_SOURCE}/{monitor_id}", {"source": source}
)
async def set_display_color_preset(self, monitor_id: int, preset: str) -> dict[str, Any]:
"""Apply a DDC/CI color preset by enum name (e.g. 'COLOR_TEMP_6500K')."""
return await self._request(
"POST", f"{API_DISPLAY_COLOR_PRESET}/{monitor_id}", {"preset": preset}
)
async def set_display_picture_mode(self, monitor_id: int, code: int) -> dict[str, Any]:
"""Apply a DDC/CI picture/scene mode (VCP 0xDC) by raw code."""
return await self._request(
"POST", f"{API_DISPLAY_PICTURE_MODE}/{monitor_id}", {"code": code}
)
class MediaServerWebSocket:
"""WebSocket client for real-time media status updates."""
@@ -0,0 +1,116 @@
"""Diagnostic binary sensors per display (primary, DDC/CI power-control support)."""
from __future__ import annotations
import logging
from typing import Any
from homeassistant.components.binary_sensor import BinarySensorEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import EntityCategory
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .api_client import MediaServerClient, MediaServerError
from .const import DOMAIN
from .display_device import display_device_info
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up per-display binary sensor entities."""
client: MediaServerClient = hass.data[DOMAIN][entry.entry_id]["client"]
try:
monitors = await client.get_display_monitors()
except MediaServerError as err:
_LOGGER.error("Failed to fetch display monitors: %s", err)
return
entities: list[Any] = []
for monitor in monitors:
entities.append(DisplayPrimaryBinarySensor(client, entry, monitor))
entities.append(DisplayPowerControlBinarySensor(client, entry, monitor))
if entities:
async_add_entities(entities)
_LOGGER.info("Added %d display binary sensor entities", len(entities))
class _DisplayBinarySensorBase(BinarySensorEntity):
"""Common boilerplate for per-display diagnostic binary sensors."""
_attr_has_entity_name = True
_attr_entity_category = EntityCategory.DIAGNOSTIC
def __init__(
self,
client: MediaServerClient,
entry: ConfigEntry,
monitor: dict[str, Any],
) -> None:
self._client = client
self._monitor_id: int = monitor["id"]
self._attr_device_info = display_device_info(entry, monitor)
class DisplayPrimaryBinarySensor(_DisplayBinarySensorBase):
"""Indicates whether the display is the OS primary monitor."""
_attr_name = "Primary display"
_attr_icon = "mdi:monitor-star"
def __init__(
self,
client: MediaServerClient,
entry: ConfigEntry,
monitor: dict[str, Any],
) -> None:
super().__init__(client, entry, monitor)
self._attr_unique_id = f"{entry.entry_id}_display_primary_{self._monitor_id}"
self._attr_is_on = bool(monitor.get("is_primary"))
async def async_update(self) -> None:
try:
monitors = await self._client.get_display_monitors()
for monitor in monitors:
if monitor["id"] == self._monitor_id:
self._attr_is_on = bool(monitor.get("is_primary"))
break
except MediaServerError as err:
_LOGGER.error("Failed to refresh primary flag for monitor %d: %s", self._monitor_id, err)
class DisplayPowerControlBinarySensor(_DisplayBinarySensorBase):
"""Indicates whether DDC/CI power control is available for this display."""
_attr_name = "Power control supported"
_attr_icon = "mdi:power-plug"
def __init__(
self,
client: MediaServerClient,
entry: ConfigEntry,
monitor: dict[str, Any],
) -> None:
super().__init__(client, entry, monitor)
self._attr_unique_id = f"{entry.entry_id}_display_power_supported_{self._monitor_id}"
self._attr_is_on = bool(monitor.get("power_supported"))
async def async_update(self) -> None:
try:
monitors = await self._client.get_display_monitors()
for monitor in monitors:
if monitor["id"] == self._monitor_id:
self._attr_is_on = bool(monitor.get("power_supported"))
break
except MediaServerError as err:
_LOGGER.error(
"Failed to refresh power_supported flag for monitor %d: %s",
self._monitor_id, err,
)
@@ -40,6 +40,10 @@ API_BROWSER_PLAY = "/api/browser/play"
API_DISPLAY_MONITORS = "/api/display/monitors"
API_DISPLAY_BRIGHTNESS = "/api/display/brightness"
API_DISPLAY_POWER = "/api/display/power"
API_DISPLAY_CONTRAST = "/api/display/contrast"
API_DISPLAY_INPUT_SOURCE = "/api/display/input_source"
API_DISPLAY_COLOR_PRESET = "/api/display/color_preset"
API_DISPLAY_PICTURE_MODE = "/api/display/picture_mode"
# Service names
SERVICE_EXECUTE_SCRIPT = "execute_script"
@@ -0,0 +1,56 @@
"""Helpers for building per-display DeviceInfo.
Each physical monitor is exposed as its own HA device (linked back to the
media-server hub via `via_device`) so that per-display entities (power
switch, brightness, future per-display sensors) cluster together, can be
placed in their own area/room, and participate in device-based automations.
"""
from __future__ import annotations
from typing import Any
from homeassistant.config_entries import ConfigEntry
from homeassistant.helpers.entity import DeviceInfo
from .const import DOMAIN
def display_label(monitor: dict[str, Any]) -> str:
"""Return a user-friendly label for a display monitor.
Resolution is appended when available so that two monitors sharing a
name (e.g. two "Generic PnP Monitor" entries) remain distinguishable.
"""
name = monitor.get("name") or f"Monitor {monitor['id']}"
resolution = monitor.get("resolution")
if resolution:
return f"{name} ({resolution})"
return name
def display_device_identifier(entry: ConfigEntry, monitor_id: int) -> tuple[str, str]:
"""Return the stable identifier tuple for a per-display device."""
return (DOMAIN, f"{entry.entry_id}_display_{monitor_id}")
def display_device_info(entry: ConfigEntry, monitor: dict[str, Any]) -> DeviceInfo:
"""Build DeviceInfo for a per-display device linked to the hub.
Prefers the manufacturer/model reported by the monitor's EDID; falls back
to integration-level defaults so devices still appear sensibly even when
EDID parsing returns blanks.
"""
manufacturer = (monitor.get("manufacturer") or "").strip() or "Remote Media Player"
model = (monitor.get("model") or "").strip() or "Display"
return DeviceInfo(
identifiers={display_device_identifier(entry, monitor["id"])},
via_device=(DOMAIN, entry.entry_id),
# HA's device tree already shows the parent hub above its children
# via `via_device`, so re-stating the entry title here would just
# duplicate the hub name on every child row.
name=display_label(monitor),
manufacturer=manufacturer,
model=model,
)
@@ -8,5 +8,5 @@
"integration_type": "device",
"iot_class": "local_push",
"requirements": ["aiohttp>=3.8.0"],
"version": "0.1.1"
"version": "0.3.0"
}
+66 -31
View File
@@ -8,11 +8,11 @@ from typing import Any
from homeassistant.components.number import NumberEntity, NumberMode
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .api_client import MediaServerClient, MediaServerError
from .const import DOMAIN
from .display_device import display_device_info
_LOGGER = logging.getLogger(__name__)
@@ -22,7 +22,7 @@ async def async_setup_entry(
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up display brightness 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"]
try:
@@ -31,25 +31,23 @@ async def async_setup_entry(
_LOGGER.error("Failed to fetch display monitors: %s", err)
return
entities = [
DisplayBrightnessNumber(
client=client,
entry=entry,
monitor=monitor,
)
for monitor in monitors
if monitor.get("brightness") is not None
]
entities: list[Any] = []
for monitor in monitors:
if monitor.get("brightness") is not None:
entities.append(DisplayBrightnessNumber(client, entry, monitor))
if monitor.get("contrast_supported"):
entities.append(DisplayContrastNumber(client, entry, monitor))
if entities:
async_add_entities(entities)
_LOGGER.info("Added %d display brightness entities", len(entities))
_LOGGER.info("Added %d display number entities", len(entities))
class DisplayBrightnessNumber(NumberEntity):
"""Number entity for controlling display brightness."""
_attr_has_entity_name = True
_attr_name = "Brightness"
_attr_native_min_value = 0
_attr_native_max_value = 100
_attr_native_step = 1
@@ -67,27 +65,9 @@ class DisplayBrightnessNumber(NumberEntity):
self._client = client
self._entry = entry
self._monitor_id: int = monitor["id"]
self._monitor_name: str = monitor.get("name", f"Monitor {monitor['id']}")
self._resolution: str | None = monitor.get("resolution")
self._attr_native_value = monitor.get("brightness")
# Use resolution in name to disambiguate same-name monitors
display_name = self._monitor_name
if self._resolution:
display_name = f"{self._monitor_name} ({self._resolution})"
self._attr_unique_id = f"{entry.entry_id}_display_brightness_{self._monitor_id}"
self._attr_name = f"Display {display_name} Brightness"
@property
def device_info(self) -> DeviceInfo:
"""Return device info."""
return DeviceInfo(
identifiers={(DOMAIN, self._entry.entry_id)},
name=self._entry.title,
manufacturer="Remote Media Player",
model="Media Server",
)
self._attr_device_info = display_device_info(entry, monitor)
async def async_set_native_value(self, value: float) -> None:
"""Set the brightness value."""
@@ -108,3 +88,58 @@ class DisplayBrightnessNumber(NumberEntity):
break
except MediaServerError as err:
_LOGGER.error("Failed to update brightness for monitor %d: %s", self._monitor_id, err)
class DisplayContrastNumber(NumberEntity):
"""Number entity for controlling DDC/CI display contrast."""
_attr_has_entity_name = True
_attr_name = "Contrast"
_attr_native_min_value = 0
_attr_native_max_value = 100
_attr_native_step = 1
_attr_native_unit_of_measurement = "%"
_attr_mode = NumberMode.SLIDER
_attr_icon = "mdi:contrast-circle"
def __init__(
self,
client: MediaServerClient,
entry: ConfigEntry,
monitor: dict[str, Any],
) -> None:
"""Initialize the display contrast entity."""
self._client = client
self._monitor_id: int = monitor["id"]
self._attr_native_value = monitor.get("contrast")
self._attr_unique_id = f"{entry.entry_id}_display_contrast_{self._monitor_id}"
self._attr_device_info = display_device_info(entry, monitor)
async def async_set_native_value(self, value: float) -> None:
"""Set the contrast value."""
try:
result = await self._client.set_display_contrast(self._monitor_id, int(value))
except MediaServerError as err:
_LOGGER.error("Failed to set contrast for monitor %d: %s", self._monitor_id, err)
return
if not result.get("success"):
_LOGGER.warning(
"Monitor %d rejected contrast %d (DDC/CI silently dropped)",
self._monitor_id, int(value),
)
await self.async_update()
self.async_write_ha_state()
return
self._attr_native_value = int(value)
self.async_write_ha_state()
async def async_update(self) -> None:
"""Fetch updated contrast from the server."""
try:
monitors = await self._client.get_display_monitors()
for monitor in monitors:
if monitor["id"] == self._monitor_id:
self._attr_native_value = monitor.get("contrast")
break
except MediaServerError as err:
_LOGGER.error("Failed to update contrast for monitor %d: %s", self._monitor_id, err)
@@ -0,0 +1,219 @@
"""Select platform: DDC/CI input source, color preset, and picture mode."""
from __future__ import annotations
import logging
from typing import Any
from homeassistant.components.select import SelectEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .api_client import MediaServerClient, MediaServerError
from .const import DOMAIN
from .display_device import display_device_info
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up per-display select entities."""
client: MediaServerClient = hass.data[DOMAIN][entry.entry_id]["client"]
try:
monitors = await client.get_display_monitors()
except MediaServerError as err:
_LOGGER.error("Failed to fetch display monitors: %s", err)
return
entities: list[Any] = []
for monitor in monitors:
if monitor.get("input_source_supported") and monitor.get("available_input_sources"):
entities.append(DisplayInputSourceSelect(client, entry, monitor))
if monitor.get("color_preset_supported") and monitor.get("available_color_presets"):
entities.append(DisplayColorPresetSelect(client, entry, monitor))
if monitor.get("picture_mode_supported") and monitor.get("available_picture_modes"):
entities.append(DisplayPictureModeSelect(client, entry, monitor))
if entities:
async_add_entities(entities)
_LOGGER.info("Added %d display select entities", len(entities))
class _DisplaySelectBase(SelectEntity):
"""Shared base for per-display selects."""
_attr_has_entity_name = True
def __init__(
self,
client: MediaServerClient,
entry: ConfigEntry,
monitor: dict[str, Any],
) -> None:
self._client = client
self._monitor_id: int = monitor["id"]
self._attr_device_info = display_device_info(entry, monitor)
class DisplayInputSourceSelect(_DisplaySelectBase):
"""Switch the monitor's active input (HDMI1, DP1, ...)."""
_attr_name = "Input source"
_attr_icon = "mdi:video-input-hdmi"
def __init__(
self,
client: MediaServerClient,
entry: ConfigEntry,
monitor: dict[str, Any],
) -> None:
super().__init__(client, entry, monitor)
self._attr_unique_id = f"{entry.entry_id}_display_input_{self._monitor_id}"
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
async def async_select_option(self, option: str) -> None:
try:
result = await self._client.set_display_input_source(self._monitor_id, option)
except MediaServerError as err:
_LOGGER.error("Failed to set input source for monitor %d: %s", self._monitor_id, err)
return
if not result.get("success"):
_LOGGER.warning(
"Monitor %d rejected input source %s (DDC/CI silently dropped)",
self._monitor_id, option,
)
# Re-read so the entity state reflects what the monitor actually did.
await self.async_update()
self.async_write_ha_state()
return
self._attr_current_option = option
self.async_write_ha_state()
async def async_update(self) -> None:
try:
monitors = await self._client.get_display_monitors()
for monitor in monitors:
if monitor["id"] == self._monitor_id:
current = monitor.get("input_source")
self._attr_current_option = current if current in self._attr_options else None
break
except MediaServerError as err:
_LOGGER.error("Failed to refresh input source for monitor %d: %s", self._monitor_id, err)
class DisplayColorPresetSelect(_DisplaySelectBase):
"""Switch the monitor's color temperature preset (sRGB / 6500K / ...)."""
_attr_name = "Color preset"
_attr_icon = "mdi:palette"
def __init__(
self,
client: MediaServerClient,
entry: ConfigEntry,
monitor: dict[str, Any],
) -> None:
super().__init__(client, entry, monitor)
self._attr_unique_id = f"{entry.entry_id}_display_color_preset_{self._monitor_id}"
self._attr_options = list(monitor.get("available_color_presets") or [])
current = monitor.get("color_preset")
self._attr_current_option = current if current in self._attr_options else None
async def async_select_option(self, option: str) -> None:
try:
result = await self._client.set_display_color_preset(self._monitor_id, option)
except MediaServerError as err:
_LOGGER.error("Failed to set color preset for monitor %d: %s", self._monitor_id, err)
return
if not result.get("success"):
_LOGGER.warning(
"Monitor %d rejected color preset %s (DDC/CI silently dropped)",
self._monitor_id, option,
)
await self.async_update()
self.async_write_ha_state()
return
self._attr_current_option = option
self.async_write_ha_state()
async def async_update(self) -> None:
try:
monitors = await self._client.get_display_monitors()
for monitor in monitors:
if monitor["id"] == self._monitor_id:
current = monitor.get("color_preset")
self._attr_current_option = current if current in self._attr_options else None
break
except MediaServerError as err:
_LOGGER.error("Failed to refresh color preset for monitor %d: %s", self._monitor_id, err)
class DisplayPictureModeSelect(_DisplaySelectBase):
"""Switch the monitor's picture/scene mode via VCP 0xDC.
The server returns options as `[{code: int, label: str}, ...]`. We use
labels as the user-facing options and keep a label→code map for writes.
"""
_attr_name = "Picture mode"
_attr_icon = "mdi:image-multiple"
def __init__(
self,
client: MediaServerClient,
entry: ConfigEntry,
monitor: dict[str, Any],
) -> None:
super().__init__(client, entry, monitor)
self._attr_unique_id = f"{entry.entry_id}_display_picture_mode_{self._monitor_id}"
modes = monitor.get("available_picture_modes") or []
self._label_to_code: dict[str, int] = {
mode["label"]: mode["code"]
for mode in modes
if "label" in mode and "code" in mode
}
self._attr_options = list(self._label_to_code.keys())
current = monitor.get("picture_mode")
self._attr_current_option = current if current in self._attr_options else None
async def async_select_option(self, option: str) -> None:
code = self._label_to_code.get(option)
if code is None:
_LOGGER.error("Unknown picture mode label: %s", option)
return
try:
result = await self._client.set_display_picture_mode(self._monitor_id, code)
except MediaServerError as err:
_LOGGER.error("Failed to set picture mode for monitor %d: %s", self._monitor_id, err)
return
if not result.get("success"):
_LOGGER.warning(
"Monitor %d rejected picture mode %s (code %d) - monitor's DDC/CI"
" implementation of VCP 0xDC may be incomplete",
self._monitor_id, option, code,
)
await self.async_update()
self.async_write_ha_state()
return
self._attr_current_option = option
self.async_write_ha_state()
async def async_update(self) -> None:
try:
monitors = await self._client.get_display_monitors()
for monitor in monitors:
if monitor["id"] == self._monitor_id:
current = monitor.get("picture_mode")
self._attr_current_option = current if current in self._attr_options else None
break
except MediaServerError as err:
_LOGGER.error("Failed to refresh picture mode for monitor %d: %s", self._monitor_id, err)
@@ -0,0 +1,76 @@
"""Diagnostic sensors exposed per display (resolution, etc.)."""
from __future__ import annotations
import logging
from typing import Any
from homeassistant.components.sensor import SensorEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import EntityCategory
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .api_client import MediaServerClient, MediaServerError
from .const import DOMAIN
from .display_device import display_device_info
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up per-display sensor entities."""
client: MediaServerClient = hass.data[DOMAIN][entry.entry_id]["client"]
try:
monitors = await client.get_display_monitors()
except MediaServerError as err:
_LOGGER.error("Failed to fetch display monitors: %s", err)
return
entities = [
DisplayResolutionSensor(client, entry, monitor)
for monitor in monitors
if monitor.get("resolution")
]
if entities:
async_add_entities(entities)
_LOGGER.info("Added %d display sensor entities", len(entities))
class DisplayResolutionSensor(SensorEntity):
"""Diagnostic sensor reporting the EDID-derived display resolution."""
_attr_has_entity_name = True
_attr_name = "Resolution"
_attr_entity_category = EntityCategory.DIAGNOSTIC
_attr_icon = "mdi:monitor-screenshot"
def __init__(
self,
client: MediaServerClient,
entry: ConfigEntry,
monitor: dict[str, Any],
) -> None:
"""Initialize the display resolution sensor."""
self._client = client
self._monitor_id: int = monitor["id"]
self._attr_native_value = monitor.get("resolution")
self._attr_unique_id = f"{entry.entry_id}_display_resolution_{self._monitor_id}"
self._attr_device_info = display_device_info(entry, monitor)
async def async_update(self) -> None:
"""Refresh resolution from the server (rarely changes)."""
try:
monitors = await self._client.get_display_monitors()
for monitor in monitors:
if monitor["id"] == self._monitor_id:
self._attr_native_value = monitor.get("resolution")
break
except MediaServerError as err:
_LOGGER.error("Failed to refresh resolution for monitor %d: %s", self._monitor_id, err)
@@ -5,14 +5,14 @@ from __future__ import annotations
import logging
from typing import Any
from homeassistant.components.switch import SwitchEntity, SwitchDeviceClass
from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .api_client import MediaServerClient, MediaServerError
from .const import DOMAIN
from .display_device import display_device_info
_LOGGER = logging.getLogger(__name__)
@@ -51,6 +51,7 @@ class DisplayPowerSwitch(SwitchEntity):
_attr_has_entity_name = True
_attr_device_class = SwitchDeviceClass.SWITCH
_attr_name = "Power"
def __init__(
self,
@@ -62,33 +63,15 @@ class DisplayPowerSwitch(SwitchEntity):
self._client = client
self._entry = entry
self._monitor_id: int = monitor["id"]
self._monitor_name: str = monitor.get("name", f"Monitor {monitor['id']}")
self._resolution: str | None = monitor.get("resolution")
self._attr_is_on = monitor.get("power_on", True)
# Use resolution in name to disambiguate same-name monitors
display_name = self._monitor_name
if self._resolution:
display_name = f"{self._monitor_name} ({self._resolution})"
self._attr_unique_id = f"{entry.entry_id}_display_power_{self._monitor_id}"
self._attr_name = f"Display {display_name} Power"
self._attr_device_info = display_device_info(entry, monitor)
@property
def icon(self) -> str:
"""Return icon based on power state."""
return "mdi:monitor" if self._attr_is_on else "mdi:monitor-off"
@property
def device_info(self) -> DeviceInfo:
"""Return device info."""
return DeviceInfo(
identifiers={(DOMAIN, self._entry.entry_id)},
name=self._entry.title,
manufacturer="Remote Media Player",
model="Media Server",
)
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the monitor on."""
try: