From 57fdeb70fb951588061d18d169424eb5751aa86f Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Fri, 15 May 2026 14:28:04 +0300 Subject: [PATCH] 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) --- media_server/routes/display.py | 79 ++++- media_server/services/display_service.py | 369 +++++++++++++++++++++-- media_server/static/css/styles.css | 87 +++++- media_server/static/js/app.js | 4 + media_server/static/js/links.js | 331 +++++++++++++++++++- media_server/static/locales/en.json | 13 + media_server/static/locales/ru.json | 13 + 7 files changed, 853 insertions(+), 43 deletions(-) diff --git a/media_server/routes/display.py b/media_server/routes/display.py index a27b1dd..00ca07b 100644 --- a/media_server/routes/display.py +++ b/media_server/routes/display.py @@ -1,4 +1,4 @@ -"""Display brightness and power control API endpoints.""" +"""Display brightness, power, contrast, input-source, color-preset and picture-mode API.""" import logging @@ -9,6 +9,10 @@ from ..auth import verify_token from ..services.display_service import ( list_monitors, set_brightness, + set_color_preset, + set_contrast, + set_input_source, + set_picture_mode, set_power, ) @@ -25,12 +29,35 @@ class PowerRequest(BaseModel): on: bool +class ContrastRequest(BaseModel): + contrast: int = Field(ge=0, le=100) + + +class InputSourceRequest(BaseModel): + source: str + + +class ColorPresetRequest(BaseModel): + preset: str + + +class PictureModeRequest(BaseModel): + code: int = Field(ge=0, le=255) + + @router.get("/monitors") async def get_monitors( - refresh: bool = False, _: str = Depends(verify_token) + refresh: bool = False, + rediscover: bool = False, + _: str = Depends(verify_token), ) -> list[dict]: - """List all connected monitors with brightness and power info.""" - monitors = list_monitors(force_refresh=refresh) + """List all connected monitors with their reported DDC/CI capabilities. + + - `refresh=true` bypasses the response TTL cache (re-reads current state). + - `rediscover=true` also drops the per-monitor capability cache, forcing + a full DDC/CI capability probe. Use after a monitor hot-swap. + """ + monitors = list_monitors(force_refresh=refresh, rediscover=rediscover) logger.debug("Found %d monitors", len(monitors)) return [m.to_dict() for m in monitors] @@ -56,3 +83,47 @@ async def set_monitor_power( if success: logger.info("Set monitor %d power %s", monitor_id, action) return {"success": success} + + +@router.post("/contrast/{monitor_id}") +async def set_monitor_contrast( + monitor_id: int, request: ContrastRequest, _: str = Depends(verify_token) +) -> dict: + """Set DDC/CI contrast for a specific monitor.""" + success = set_contrast(monitor_id, request.contrast) + if success: + logger.info("Set monitor %d contrast to %d", monitor_id, request.contrast) + return {"success": success} + + +@router.post("/input_source/{monitor_id}") +async def set_monitor_input_source( + monitor_id: int, request: InputSourceRequest, _: str = Depends(verify_token) +) -> dict: + """Switch a monitor's DDC/CI input source (e.g. HDMI1, DP1).""" + success = set_input_source(monitor_id, request.source) + if success: + logger.info("Set monitor %d input source to %s", monitor_id, request.source) + return {"success": success} + + +@router.post("/color_preset/{monitor_id}") +async def set_monitor_color_preset( + monitor_id: int, request: ColorPresetRequest, _: str = Depends(verify_token) +) -> dict: + """Apply a DDC/CI color preset (color temperature) to the monitor.""" + success = set_color_preset(monitor_id, request.preset) + if success: + logger.info("Set monitor %d color preset to %s", monitor_id, request.preset) + return {"success": success} + + +@router.post("/picture_mode/{monitor_id}") +async def set_monitor_picture_mode( + monitor_id: int, request: PictureModeRequest, _: str = Depends(verify_token) +) -> dict: + """Apply a DDC/CI picture/scene mode (VCP 0xDC) by raw code.""" + success = set_picture_mode(monitor_id, request.code) + if success: + logger.info("Set monitor %d picture mode to code %d", monitor_id, request.code) + return {"success": success} diff --git a/media_server/services/display_service.py b/media_server/services/display_service.py index 3500fe0..1fd01fd 100644 --- a/media_server/services/display_service.py +++ b/media_server/services/display_service.py @@ -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 ". +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 diff --git a/media_server/static/css/styles.css b/media_server/static/css/styles.css index 9e4a4cd..7a2ebba 100644 --- a/media_server/static/css/styles.css +++ b/media_server/static/css/styles.css @@ -8022,24 +8022,36 @@ select option { background: rgba(var(--copper-rgb), 0.06) !important; } -/* Brightness control row */ -.display-container .display-brightness-control { - display: flex; +/* Slider rows (brightness + contrast share this layout) */ +.display-container .display-slider-row { + display: grid; + grid-template-columns: 18px minmax(0, auto) 1fr auto; align-items: center; - gap: 14px; + gap: 12px; +} +.display-container .display-slider-row.display-brightness-control { padding-top: 16px; border-top: 1px solid var(--rule); } -.display-container .display-brightness-icon { +.display-container .display-slider-icon { color: var(--ink-mute); width: 18px !important; height: 18px !important; flex-shrink: 0; } -.display-container .display-brightness-slider { +.display-container .display-slider-label { + font-family: var(--mono); + font-size: 9px; + letter-spacing: 0.22em; + text-transform: uppercase; + color: var(--ink-mute); + white-space: nowrap; +} +.display-container .display-slider { -webkit-appearance: none; appearance: none; flex: 1; + width: 100%; height: 2px !important; background: var(--rule-strong); border-radius: 0; @@ -8049,7 +8061,7 @@ select option { border: 0 !important; min-width: 0; } -.display-container .display-brightness-slider::-webkit-slider-thumb { +.display-container .display-slider::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; width: 14px; @@ -8059,20 +8071,24 @@ select option { box-shadow: 0 0 12px var(--copper-glow); border: 0; cursor: grab; + transition: transform 140ms var(--ease); } -.display-container .display-brightness-slider::-moz-range-thumb { +.display-container .display-slider::-moz-range-thumb { width: 14px; height: 14px; background: var(--copper); border-radius: 50%; border: 0; cursor: grab; + transition: transform 140ms var(--ease); } -.display-container .display-brightness-slider:disabled { +.display-container .display-slider:hover::-webkit-slider-thumb { transform: scale(1.15); } +.display-container .display-slider:hover::-moz-range-thumb { transform: scale(1.15); } +.display-container .display-slider:disabled { opacity: 0.4; cursor: not-allowed; } -.display-container .display-brightness-value { +.display-container .display-slider-value { font-family: var(--mono); font-size: 12px; color: var(--copper); @@ -8082,6 +8098,57 @@ select option { text-align: right; letter-spacing: 0.04em; } + +/* Picture tuning section — input source, color preset, picture mode. + The underlying + ${contrastValue}% + `; + } + + // Build the picture-tuning selects (input source / color preset / picture mode). + const tuningRows = []; + + // Each tuning field renders a hidden + ${options} + + `); + tuningTargets.push({ + kind: 'input', + monitorId: monitor.id, + items: monitor.available_input_sources.map(src => ({ + value: src, + icon: inputSourceIcon(src), + label: humanizeInputSource(src), + })), + }); + } + + if (monitor.color_preset_supported && monitor.available_color_presets.length > 0) { + const current = monitor.color_preset; + const options = monitor.available_color_presets.map(p => { + const selected = p === current ? 'selected' : ''; + return ``; + }).join(''); + tuningRows.push(` +
+ ${t('display.color_preset')} + +
`); + tuningTargets.push({ + kind: 'color', + monitorId: monitor.id, + items: monitor.available_color_presets.map(p => ({ + value: p, + icon: ICON_THERMOMETER, + label: humanizeColorPreset(p), + })), + }); + } + + if (monitor.picture_mode_supported && monitor.available_picture_modes.length > 0) { + const current = monitor.picture_mode_code; + const options = monitor.available_picture_modes.map(m => { + const selected = m.code === current ? 'selected' : ''; + return ``; + }).join(''); + tuningRows.push(` +
+ ${t('display.picture_mode')} + +
`); + tuningTargets.push({ + kind: 'mode', + monitorId: monitor.id, + items: monitor.available_picture_modes.map(m => ({ + value: String(m.code), + icon: pictureModeIcon(m.label), + label: m.label, + })), + }); + } + + pendingIconSelects.push(...tuningTargets); + + const tuningBlock = tuningRows.length > 0 + ? `
+
${t('display.tuning')}
+
${tuningRows.join('')}
+
` + : ''; + card.innerHTML = `
@@ -76,18 +287,40 @@ export async function loadDisplayMonitors() {
${powerBtn} -
- +
+ - ${t('display.brightness')} + - ${brightnessValue}% -
`; + ${brightnessValue}% +
+ ${contrastRow} + ${tuningBlock}`; container.appendChild(card); }); + + // Enhance every tuning