From 4156dedf5e44cf48894775941595909d619ab80b Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Fri, 15 May 2026 14:46:50 +0300 Subject: [PATCH] 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). --- .../remote_media_player/__init__.py | 10 +- .../remote_media_player/api_client.py | 36 ++- .../remote_media_player/binary_sensor.py | 116 ++++++++++ .../remote_media_player/const.py | 4 + .../remote_media_player/display_device.py | 56 +++++ .../remote_media_player/manifest.json | 2 +- .../remote_media_player/number.py | 97 +++++--- .../remote_media_player/select.py | 219 ++++++++++++++++++ .../remote_media_player/sensor.py | 76 ++++++ .../remote_media_player/switch.py | 25 +- 10 files changed, 583 insertions(+), 58 deletions(-) create mode 100644 custom_components/remote_media_player/binary_sensor.py create mode 100644 custom_components/remote_media_player/display_device.py create mode 100644 custom_components/remote_media_player/select.py create mode 100644 custom_components/remote_media_player/sensor.py diff --git a/custom_components/remote_media_player/__init__.py b/custom_components/remote_media_player/__init__.py index e260b29..57d3796 100644 --- a/custom_components/remote_media_player/__init__.py +++ b/custom_components/remote_media_player/__init__.py @@ -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( diff --git a/custom_components/remote_media_player/api_client.py b/custom_components/remote_media_player/api_client.py index beb2d82..79ac05d 100644 --- a/custom_components/remote_media_player/api_client.py +++ b/custom_components/remote_media_player/api_client.py @@ -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.""" diff --git a/custom_components/remote_media_player/binary_sensor.py b/custom_components/remote_media_player/binary_sensor.py new file mode 100644 index 0000000..452a19f --- /dev/null +++ b/custom_components/remote_media_player/binary_sensor.py @@ -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, + ) diff --git a/custom_components/remote_media_player/const.py b/custom_components/remote_media_player/const.py index 24252dc..2c5a26d 100644 --- a/custom_components/remote_media_player/const.py +++ b/custom_components/remote_media_player/const.py @@ -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" diff --git a/custom_components/remote_media_player/display_device.py b/custom_components/remote_media_player/display_device.py new file mode 100644 index 0000000..a657d4c --- /dev/null +++ b/custom_components/remote_media_player/display_device.py @@ -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, + ) diff --git a/custom_components/remote_media_player/manifest.json b/custom_components/remote_media_player/manifest.json index c7ad881..7264b60 100644 --- a/custom_components/remote_media_player/manifest.json +++ b/custom_components/remote_media_player/manifest.json @@ -8,5 +8,5 @@ "integration_type": "device", "iot_class": "local_push", "requirements": ["aiohttp>=3.8.0"], - "version": "0.1.1" + "version": "0.3.0" } diff --git a/custom_components/remote_media_player/number.py b/custom_components/remote_media_player/number.py index f636c73..77e6a96 100644 --- a/custom_components/remote_media_player/number.py +++ b/custom_components/remote_media_player/number.py @@ -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) diff --git a/custom_components/remote_media_player/select.py b/custom_components/remote_media_player/select.py new file mode 100644 index 0000000..aeaf6d2 --- /dev/null +++ b/custom_components/remote_media_player/select.py @@ -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) diff --git a/custom_components/remote_media_player/sensor.py b/custom_components/remote_media_player/sensor.py new file mode 100644 index 0000000..a2b17d8 --- /dev/null +++ b/custom_components/remote_media_player/sensor.py @@ -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) diff --git a/custom_components/remote_media_player/switch.py b/custom_components/remote_media_player/switch.py index 75c8674..64a1555 100644 --- a/custom_components/remote_media_player/switch.py +++ b/custom_components/remote_media_player/switch.py @@ -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: