feat(displays): expose DDC/CI contrast, input source, color preset, picture mode
Backend (routes/display.py, services/display_service.py):
- Probe DDC/CI capabilities per monitor at enumeration time
- New endpoints POST /api/display/{contrast,input_source,color_preset,picture_mode}/{id}
- Picture mode goes through raw VCP 0xDC since monitorcontrol has no
high-level wrapper; labels follow MCCS spec with vendor-friendly fallbacks
- Each capability reports a *_supported flag so the UI can hide rows that
the hardware does not advertise
Frontend (links.js, app.js, styles.css, locales):
- Monitor cards grow a contrast slider (same editorial copper treatment
as brightness) and a "PICTURE TUNING" section beneath
- Picture tuning uses the IconSelect widget (matching the audio device
selector): per-port icons (HDMI, DisplayPort, DVI, VGA, USB-C),
thermometer for color temps, per-mode icons (movie reel, gamepad,
ball, etc.) for picture modes
- Humanizers turn SHOUT_CASE enum names into readable labels
(COLOR_TEMP_6500K -> "6500 K", HDMI1 -> "HDMI 1")
- 14 new i18n keys per locale (en/ru)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
"""Display brightness and power control service."""
|
||||
"""Display brightness, power, contrast, input-source and color-preset control."""
|
||||
|
||||
import ctypes
|
||||
import ctypes.wintypes
|
||||
@@ -6,10 +6,33 @@ import logging
|
||||
import platform
|
||||
import struct
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# VCP 0xDC "Display Application" — picture / scene mode.
|
||||
# Vendors deviate from the MCCS spec, but these labels match the standard
|
||||
# meanings and cover what most monitors report through their capability
|
||||
# string. Unknown codes fall back to "Mode <n>".
|
||||
PICTURE_MODE_VCP = 0xDC
|
||||
PICTURE_MODE_LABELS: dict[int, str] = {
|
||||
0x00: "Default",
|
||||
0x01: "Standalone",
|
||||
0x02: "Mixed",
|
||||
0x03: "Productivity",
|
||||
0x04: "Movie",
|
||||
0x05: "Game",
|
||||
0x06: "Sports",
|
||||
0x07: "Professional",
|
||||
0x08: "Standard",
|
||||
0x09: "Default",
|
||||
0x0A: "Movie (Reduced Effects)",
|
||||
0x0B: "Movie (Enhanced)",
|
||||
0x0C: "User 1",
|
||||
0x0D: "User 2",
|
||||
0x0E: "User 3",
|
||||
}
|
||||
|
||||
_sbc = None
|
||||
_monitorcontrol = None
|
||||
|
||||
@@ -32,7 +55,7 @@ def _load_monitorcontrol():
|
||||
import monitorcontrol
|
||||
_monitorcontrol = monitorcontrol
|
||||
except ImportError:
|
||||
logger.warning("monitorcontrol not installed - display power control unavailable")
|
||||
logger.warning("monitorcontrol not installed - DDC/CI control unavailable")
|
||||
return _monitorcontrol
|
||||
|
||||
|
||||
@@ -64,6 +87,18 @@ class MonitorInfo:
|
||||
manufacturer: str = ""
|
||||
resolution: str | None = None
|
||||
is_primary: bool = False
|
||||
contrast: int | None = None
|
||||
contrast_supported: bool = False
|
||||
input_source: str | None = None
|
||||
available_input_sources: list[str] = field(default_factory=list)
|
||||
input_source_supported: bool = False
|
||||
color_preset: str | None = None
|
||||
available_color_presets: list[str] = field(default_factory=list)
|
||||
color_preset_supported: bool = False
|
||||
picture_mode: str | None = None
|
||||
picture_mode_code: int | None = None
|
||||
available_picture_modes: list[dict] = field(default_factory=list)
|
||||
picture_mode_supported: bool = False
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
@@ -76,6 +111,18 @@ class MonitorInfo:
|
||||
"manufacturer": self.manufacturer,
|
||||
"resolution": self.resolution,
|
||||
"is_primary": self.is_primary,
|
||||
"contrast": self.contrast,
|
||||
"contrast_supported": self.contrast_supported,
|
||||
"input_source": self.input_source,
|
||||
"available_input_sources": self.available_input_sources,
|
||||
"input_source_supported": self.input_source_supported,
|
||||
"color_preset": self.color_preset,
|
||||
"available_color_presets": self.available_color_presets,
|
||||
"color_preset_supported": self.color_preset_supported,
|
||||
"picture_mode": self.picture_mode,
|
||||
"picture_mode_code": self.picture_mode_code,
|
||||
"available_picture_modes": self.available_picture_modes,
|
||||
"picture_mode_supported": self.picture_mode_supported,
|
||||
}
|
||||
|
||||
|
||||
@@ -137,17 +184,176 @@ def _mark_primary(monitors: list[MonitorInfo]) -> None:
|
||||
monitors[0].is_primary = True
|
||||
|
||||
|
||||
# Cache for monitor list
|
||||
# Short TTL cache of the assembled monitor list (full response).
|
||||
_monitor_cache: list[MonitorInfo] | None = None
|
||||
_cache_time: float = 0
|
||||
_CACHE_TTL = 5.0 # seconds
|
||||
|
||||
# Per-monitor cache of static capabilities (option lists + support flags).
|
||||
# DDC/CI capability discovery is the slow part — it only changes when a
|
||||
# monitor is replaced or rewired, so we probe it once per monitor and reuse
|
||||
# it across refreshes. Cleared on explicit `rediscover` or when the monitor
|
||||
# count changes (cheap stale-detection for hot-plug events).
|
||||
_static_cache: dict[int, dict] = {}
|
||||
_static_cache_monitor_count: int = -1
|
||||
|
||||
def list_monitors(force_refresh: bool = False) -> list[MonitorInfo]:
|
||||
"""List all connected monitors with their current brightness."""
|
||||
global _monitor_cache, _cache_time
|
||||
|
||||
if not force_refresh and _monitor_cache is not None and (time.time() - _cache_time) < _CACHE_TTL:
|
||||
def _enum_name(value, enum_cls=None) -> str | None:
|
||||
"""Best-effort name for an enum or raw int returned by monitorcontrol.
|
||||
|
||||
monitorcontrol's getters sometimes hand back raw ints when the monitor
|
||||
reports a value the library wraps incompletely. Re-map those through the
|
||||
matching enum class so HA selects still receive symbolic option names.
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
name = getattr(value, "name", None)
|
||||
if name:
|
||||
return name
|
||||
if enum_cls is not None:
|
||||
try:
|
||||
return enum_cls(value).name
|
||||
except (ValueError, KeyError):
|
||||
pass
|
||||
return str(value)
|
||||
|
||||
|
||||
def _probe_static_open(mon, mc, monitor_id: int) -> dict:
|
||||
"""Probe per-monitor static capabilities.
|
||||
|
||||
Must be called inside an open `with mon:` DDC/CI context. Tries each
|
||||
feature once to confirm the monitor responds, and enumerates option
|
||||
lists from the capability string. Heavy: this is what the cache is for.
|
||||
"""
|
||||
static = {
|
||||
"contrast_supported": False,
|
||||
"input_source_supported": False,
|
||||
"available_input_sources": [],
|
||||
"color_preset_supported": False,
|
||||
"available_color_presets": [],
|
||||
"picture_mode_supported": False,
|
||||
"available_picture_modes": [],
|
||||
}
|
||||
|
||||
try:
|
||||
caps = mon.get_vcp_capabilities() or {}
|
||||
except Exception as e:
|
||||
caps = {}
|
||||
logger.debug("Monitor %d: get_vcp_capabilities failed: %s", monitor_id, e)
|
||||
|
||||
try:
|
||||
mon.get_contrast()
|
||||
static["contrast_supported"] = True
|
||||
except Exception as e:
|
||||
logger.debug("Monitor %d: contrast unsupported: %s", monitor_id, e)
|
||||
|
||||
try:
|
||||
mon.get_input_source()
|
||||
static["input_source_supported"] = True
|
||||
except Exception as e:
|
||||
logger.debug("Monitor %d: input_source unsupported: %s", monitor_id, e)
|
||||
|
||||
inputs = caps.get("inputs") or []
|
||||
input_enum = mc.InputSource if mc else None
|
||||
static["available_input_sources"] = [
|
||||
n for n in (_enum_name(s, input_enum) for s in inputs) if n is not None
|
||||
]
|
||||
|
||||
try:
|
||||
mon.get_color_preset()
|
||||
static["color_preset_supported"] = True
|
||||
if mc is not None:
|
||||
static["available_color_presets"] = [p.name for p in mc.ColorPreset]
|
||||
except Exception as e:
|
||||
logger.debug("Monitor %d: color_preset unsupported: %s", monitor_id, e)
|
||||
|
||||
# Picture / scene mode (VCP 0xDC) — not exposed by monitorcontrol's
|
||||
# high-level API, so probe via raw VCP transport.
|
||||
try:
|
||||
mon.vcp.get_vcp_feature(PICTURE_MODE_VCP)
|
||||
static["picture_mode_supported"] = True
|
||||
cmds = caps.get("cmds") or {}
|
||||
declared = cmds.get(PICTURE_MODE_VCP)
|
||||
codes = sorted(declared) if declared else sorted(PICTURE_MODE_LABELS.keys())
|
||||
static["available_picture_modes"] = [
|
||||
{"code": c, "label": PICTURE_MODE_LABELS.get(c, f"Mode {c}")}
|
||||
for c in codes
|
||||
]
|
||||
except Exception as e:
|
||||
logger.debug("Monitor %d: picture_mode unsupported: %s", monitor_id, e)
|
||||
|
||||
return static
|
||||
|
||||
|
||||
def _probe_dynamic_open(mon, mc, monitor_id: int, static: dict) -> dict:
|
||||
"""Read current values for features known to be supported.
|
||||
|
||||
Must be called inside an open `with mon:` context. Skips reads for
|
||||
unsupported features (saves one I2C roundtrip each), so the warm path
|
||||
only touches features the monitor actually responds to.
|
||||
"""
|
||||
dynamic = {
|
||||
"power_on": True,
|
||||
"contrast": None,
|
||||
"input_source": None,
|
||||
"color_preset": None,
|
||||
"picture_mode": None,
|
||||
"picture_mode_code": None,
|
||||
}
|
||||
|
||||
try:
|
||||
dynamic["power_on"] = mon.get_power_mode() == mc.PowerMode.on
|
||||
except Exception as e:
|
||||
logger.debug("Monitor %d: power readback failed: %s", monitor_id, e)
|
||||
|
||||
if static.get("contrast_supported"):
|
||||
try:
|
||||
dynamic["contrast"] = mon.get_contrast()
|
||||
except Exception as e:
|
||||
logger.debug("Monitor %d: contrast readback failed: %s", monitor_id, e)
|
||||
|
||||
if static.get("input_source_supported"):
|
||||
try:
|
||||
src = mon.get_input_source()
|
||||
dynamic["input_source"] = _enum_name(src, mc.InputSource if mc else None)
|
||||
except Exception as e:
|
||||
logger.debug("Monitor %d: input_source readback failed: %s", monitor_id, e)
|
||||
|
||||
if static.get("color_preset_supported"):
|
||||
try:
|
||||
preset = mon.get_color_preset()
|
||||
dynamic["color_preset"] = _enum_name(preset, mc.ColorPreset if mc else None)
|
||||
except Exception as e:
|
||||
logger.debug("Monitor %d: color_preset readback failed: %s", monitor_id, e)
|
||||
|
||||
if static.get("picture_mode_supported"):
|
||||
try:
|
||||
current, _maximum = mon.vcp.get_vcp_feature(PICTURE_MODE_VCP)
|
||||
dynamic["picture_mode_code"] = current
|
||||
dynamic["picture_mode"] = PICTURE_MODE_LABELS.get(current, f"Mode {current}")
|
||||
except Exception as e:
|
||||
logger.debug("Monitor %d: picture_mode readback failed: %s", monitor_id, e)
|
||||
|
||||
return dynamic
|
||||
|
||||
|
||||
def list_monitors(force_refresh: bool = False, rediscover: bool = False) -> list[MonitorInfo]:
|
||||
"""List all connected monitors with their current state.
|
||||
|
||||
Args:
|
||||
force_refresh: bypass the short TTL response cache.
|
||||
rediscover: also drop the per-monitor static capability cache, so the
|
||||
next probe re-runs DDC/CI capability discovery. Use after hot-plug
|
||||
or when a monitor's reported capabilities change.
|
||||
"""
|
||||
global _monitor_cache, _cache_time, _static_cache_monitor_count
|
||||
|
||||
if (
|
||||
not force_refresh
|
||||
and not rediscover
|
||||
and _monitor_cache is not None
|
||||
and (time.time() - _cache_time) < _CACHE_TTL
|
||||
):
|
||||
return _monitor_cache
|
||||
|
||||
sbc = _load_sbc()
|
||||
@@ -159,7 +365,13 @@ def list_monitors(force_refresh: bool = False) -> list[MonitorInfo]:
|
||||
info_list = sbc.list_monitors_info()
|
||||
brightnesses = sbc.get_brightness()
|
||||
|
||||
# Get DDC/CI monitors for power state
|
||||
# Invalidate the static cache on explicit rediscover OR on topology
|
||||
# change (hot-plug / disconnect). Both indicate the cached probe is
|
||||
# potentially stale.
|
||||
if rediscover or len(info_list) != _static_cache_monitor_count:
|
||||
_static_cache.clear()
|
||||
_static_cache_monitor_count = len(info_list)
|
||||
|
||||
mc = _load_monitorcontrol()
|
||||
ddc_monitors = []
|
||||
if mc:
|
||||
@@ -181,25 +393,44 @@ def list_monitors(force_refresh: bool = False) -> list[MonitorInfo]:
|
||||
edid = info.get("edid", "")
|
||||
resolution = _parse_edid_resolution(edid) if edid else None
|
||||
|
||||
# Read power state via DDC/CI
|
||||
power_on = True
|
||||
static: dict = {}
|
||||
dynamic: dict = {}
|
||||
|
||||
# Open the DDC handle ONCE; do static probe (if needed) + dynamic
|
||||
# readback inside the same context. Opening the handle is the
|
||||
# expensive part — keep both phases under one open.
|
||||
if power_supported and i < len(ddc_monitors):
|
||||
try:
|
||||
with ddc_monitors[i] as mon:
|
||||
power_mode = mon.get_power_mode()
|
||||
power_on = power_mode == mc.PowerMode.on
|
||||
except Exception:
|
||||
pass
|
||||
if i not in _static_cache:
|
||||
_static_cache[i] = _probe_static_open(mon, mc, i)
|
||||
static = _static_cache[i]
|
||||
dynamic = _probe_dynamic_open(mon, mc, i, static)
|
||||
except Exception as e:
|
||||
logger.debug("Monitor %d: DDC/CI session failed: %s", i, e)
|
||||
static = _static_cache.get(i, {})
|
||||
|
||||
monitors.append(MonitorInfo(
|
||||
id=i,
|
||||
name=name,
|
||||
brightness=brightness,
|
||||
power_supported=power_supported,
|
||||
power_on=power_on,
|
||||
power_on=dynamic.get("power_on", True),
|
||||
model=model,
|
||||
manufacturer=manufacturer,
|
||||
resolution=resolution,
|
||||
contrast=dynamic.get("contrast"),
|
||||
contrast_supported=static.get("contrast_supported", False),
|
||||
input_source=dynamic.get("input_source"),
|
||||
available_input_sources=static.get("available_input_sources", []),
|
||||
input_source_supported=static.get("input_source_supported", False),
|
||||
color_preset=dynamic.get("color_preset"),
|
||||
available_color_presets=static.get("available_color_presets", []),
|
||||
color_preset_supported=static.get("color_preset_supported", False),
|
||||
picture_mode=dynamic.get("picture_mode"),
|
||||
picture_mode_code=dynamic.get("picture_mode_code"),
|
||||
available_picture_modes=static.get("available_picture_modes", []),
|
||||
picture_mode_supported=static.get("picture_mode_supported", False),
|
||||
))
|
||||
except Exception as e:
|
||||
logger.error("Failed to enumerate monitors: %s", e)
|
||||
@@ -234,9 +465,7 @@ def set_brightness(monitor_id: int, value: int) -> bool:
|
||||
value = max(0, min(100, value))
|
||||
try:
|
||||
sbc.set_brightness(value, display=monitor_id)
|
||||
# Invalidate cache
|
||||
global _monitor_cache
|
||||
_monitor_cache = None
|
||||
_invalidate_cache()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error("Failed to set brightness for monitor %d: %s", monitor_id, e)
|
||||
@@ -262,10 +491,106 @@ def set_power(monitor_id: int, on: bool) -> bool:
|
||||
else:
|
||||
monitor.set_power_mode(mc.PowerMode.off_soft)
|
||||
|
||||
# Invalidate cache
|
||||
global _monitor_cache
|
||||
_monitor_cache = None
|
||||
_invalidate_cache()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error("Failed to set power for monitor %d: %s", monitor_id, e)
|
||||
return False
|
||||
|
||||
|
||||
def set_contrast(monitor_id: int, value: int) -> bool:
|
||||
"""Set contrast for a specific monitor (0-100) via DDC/CI."""
|
||||
mc = _load_monitorcontrol()
|
||||
if mc is None:
|
||||
return False
|
||||
|
||||
value = max(0, min(100, value))
|
||||
try:
|
||||
ddc_monitors = mc.get_monitors()
|
||||
if monitor_id >= len(ddc_monitors):
|
||||
return False
|
||||
with ddc_monitors[monitor_id] as monitor:
|
||||
monitor.set_contrast(value)
|
||||
_invalidate_cache()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error("Failed to set contrast for monitor %d: %s", monitor_id, e)
|
||||
return False
|
||||
|
||||
|
||||
def set_input_source(monitor_id: int, source: str) -> bool:
|
||||
"""Set the DDC/CI input source by enum name (e.g. 'HDMI1', 'DP1')."""
|
||||
mc = _load_monitorcontrol()
|
||||
if mc is None:
|
||||
return False
|
||||
|
||||
try:
|
||||
target = mc.InputSource[source]
|
||||
except KeyError:
|
||||
logger.error("Unknown input source: %s", source)
|
||||
return False
|
||||
|
||||
try:
|
||||
ddc_monitors = mc.get_monitors()
|
||||
if monitor_id >= len(ddc_monitors):
|
||||
return False
|
||||
with ddc_monitors[monitor_id] as monitor:
|
||||
monitor.set_input_source(target)
|
||||
_invalidate_cache()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error("Failed to set input source for monitor %d: %s", monitor_id, e)
|
||||
return False
|
||||
|
||||
|
||||
def set_color_preset(monitor_id: int, preset: str) -> bool:
|
||||
"""Set the DDC/CI color preset by enum name (e.g. 'COLOR_TEMP_6500K')."""
|
||||
mc = _load_monitorcontrol()
|
||||
if mc is None:
|
||||
return False
|
||||
|
||||
try:
|
||||
target = mc.ColorPreset[preset]
|
||||
except KeyError:
|
||||
logger.error("Unknown color preset: %s", preset)
|
||||
return False
|
||||
|
||||
try:
|
||||
ddc_monitors = mc.get_monitors()
|
||||
if monitor_id >= len(ddc_monitors):
|
||||
return False
|
||||
with ddc_monitors[monitor_id] as monitor:
|
||||
monitor.set_color_preset(target)
|
||||
_invalidate_cache()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error("Failed to set color preset for monitor %d: %s", monitor_id, e)
|
||||
return False
|
||||
|
||||
|
||||
def set_picture_mode(monitor_id: int, code: int) -> bool:
|
||||
"""Set the DDC/CI picture/scene mode (VCP 0xDC) by raw code."""
|
||||
mc = _load_monitorcontrol()
|
||||
if mc is None:
|
||||
return False
|
||||
|
||||
if not 0 <= code <= 255:
|
||||
logger.error("Picture mode code %d out of range", code)
|
||||
return False
|
||||
|
||||
try:
|
||||
ddc_monitors = mc.get_monitors()
|
||||
if monitor_id >= len(ddc_monitors):
|
||||
return False
|
||||
with ddc_monitors[monitor_id] as monitor:
|
||||
monitor.vcp.set_vcp_feature(PICTURE_MODE_VCP, code)
|
||||
_invalidate_cache()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error("Failed to set picture mode for monitor %d: %s", monitor_id, e)
|
||||
return False
|
||||
|
||||
|
||||
def _invalidate_cache() -> None:
|
||||
global _monitor_cache
|
||||
_monitor_cache = None
|
||||
|
||||
Reference in New Issue
Block a user