Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 68e338de4e | |||
| 4156dedf5e |
+17
-5
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user