d131ba461c
Lint & Test / test (push) Successful in 20s
Security - Default scripts_management, callbacks_management, links_management, and media_folders_management to False so a leaked token cannot escalate to RCE through admin CRUD endpoints. - TokenSpec + scope hierarchy (read | control | admin); legacy bare-string api_tokens entries promote to admin for back-compat. Management endpoints now require admin scope. - WebSocket subprotocol auth (Sec-WebSocket-Protocol: media-server.token.<T>) preferred over ?token= query so the token no longer lands in URL/history/ Referer; query fallback retained for HA integration back-compat. - Origin allow-list check on the WS endpoint (CSWSH defence). - In-process token-bucket rate limiter: 5/min for failed auths, 10/min for /api/scripts/execute and /api/callbacks/execute. - shell=False subprocess path (shlex.split) + per-parameter regex `pattern` in ScriptParameterConfig to harden shell=true scripts against parameter injection (Windows cmd.exe env-var expansion). - CSP gains form-action, worker-src, manifest-src directives. - Refuse cors_origins=["*"] at startup; strip token=... from uvicorn access logs; validate Gitea release tag against strict SemVer regex. - noopener noreferrer + no-referrer referrerpolicy on every outbound link. - icacls hardening of config.yaml on Windows (current user + SYSTEM + Administrators only); 0600 still enforced on POSIX. - WS volume handler clamps input and never drops the socket on bad messages. Performance - Album-art read in windows_media gated by track key — was decoding the WinRT thumbnail twice per second regardless of track changes. - /api/media/artwork returns content-derived ETag + Cache-Control so the browser sends If-None-Match and gets 304s on track repeats. - Foreground-service ctypes argtypes hoisted to one-time module init (was re-declaring ~14 prototypes per probe). - display_service _static_cache keyed by (edid_hash, ...) tuple with eviction of disappeared monitors — fixes stale capabilities on hot-plug swaps where the new topology has the same monitor count. - Visualizer rAF loop paused on document.hidden, resumed on visible. Reliability / bug fixes - Lifespan rewritten as try/yield/finally so a partial-startup failure cannot orphan background tasks or executors. - _run_callback in routes/media.py keeps a strong task ref (GC-safe) and uses the dedicated callback executor instead of the default pool. - macos_media.set_volume() no longer always returns True. - TrayManager._restart_requested initialised in __init__; set before signalling exit so the main thread observes it correctly. - Missing static_dir now logs a WARNING instead of silent UI disable. UX / accessibility / PWA - manifest.json theme_color and background_color match the Studio Reference base (#0E0D0B); added id and scope for PWA installability. - ARIA on mini-player icon buttons; inner SVGs marked aria-hidden. - OS mediaSession API wired so headset / lockscreen / Bluetooth buttons drive play/pause/next/prev/seek and show track metadata + artwork. Observability - X-Request-ID middleware (accept upstream id if it matches a safe regex, otherwise UUID4); request_id_var added to ContextVars and included in every log line alongside the token label. - Audit log (append-only JSONL) for every script + callback execution, including the on_play/on_pause/etc. event callbacks. Background-thread writer; queue capped; flushed in lifespan teardown. Deployment - proxy_headers + forwarded_allow_ips plumbed through Settings → uvicorn.Config for reverse-proxy installs. - HTTPS support via ssl_certfile + ssl_keyfile (+ optional password); startup refuses to launch with only one of the pair set. - Thumbnail cache moved from project-root .cache to %LOCALAPPDATA%/media-server/cache (Windows) and $XDG_CACHE_HOME/media-server/thumbnails (POSIX). Tests - 35 new tests across auth scopes, rate limiter, browser path traversal (../ NUL UNC absolute), script-param validation incl. regex, Gitea tag whitelist, config atomic write + POSIX perms. 47 passed / 4 skipped.
677 lines
24 KiB
Python
677 lines
24 KiB
Python
"""Display brightness, power, contrast, input-source and color-preset control."""
|
|
|
|
import ctypes
|
|
import ctypes.wintypes
|
|
import logging
|
|
import platform
|
|
import struct
|
|
import time
|
|
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
|
|
|
|
|
|
def _load_sbc():
|
|
global _sbc
|
|
if _sbc is None:
|
|
try:
|
|
import screen_brightness_control as sbc
|
|
_sbc = sbc
|
|
except ImportError:
|
|
logger.warning("screen_brightness_control not installed - brightness control unavailable")
|
|
return _sbc
|
|
|
|
|
|
def _load_monitorcontrol():
|
|
global _monitorcontrol
|
|
if _monitorcontrol is None:
|
|
try:
|
|
import monitorcontrol
|
|
_monitorcontrol = monitorcontrol
|
|
except ImportError:
|
|
logger.warning("monitorcontrol not installed - DDC/CI control unavailable")
|
|
return _monitorcontrol
|
|
|
|
|
|
def _parse_edid_resolution(edid_hex: str) -> str | None:
|
|
"""Parse resolution from EDID hex string (first detailed timing descriptor)."""
|
|
try:
|
|
edid = bytes.fromhex(edid_hex)
|
|
if len(edid) < 58:
|
|
return None
|
|
dtd = edid[54:]
|
|
pixel_clock = struct.unpack('<H', dtd[0:2])[0]
|
|
if pixel_clock == 0:
|
|
return None
|
|
h_active = dtd[2] | ((dtd[4] >> 4) << 8)
|
|
v_active = dtd[5] | ((dtd[7] >> 4) << 8)
|
|
return f"{h_active}x{v_active}"
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
@dataclass
|
|
class MonitorInfo:
|
|
id: int
|
|
name: str
|
|
brightness: int | None
|
|
power_supported: bool
|
|
power_on: bool = True
|
|
model: str = ""
|
|
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 {
|
|
"id": self.id,
|
|
"name": self.name,
|
|
"brightness": self.brightness,
|
|
"power_supported": self.power_supported,
|
|
"power_on": self.power_on,
|
|
"model": self.model,
|
|
"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,
|
|
}
|
|
|
|
|
|
def _detect_primary_resolution() -> str | None:
|
|
"""Detect the primary display resolution via Windows API."""
|
|
if platform.system() != "Windows":
|
|
return None
|
|
try:
|
|
class MONITORINFO(ctypes.Structure):
|
|
_fields_ = [
|
|
("cbSize", ctypes.wintypes.DWORD),
|
|
("rcMonitor", ctypes.wintypes.RECT),
|
|
("rcWork", ctypes.wintypes.RECT),
|
|
("dwFlags", ctypes.wintypes.DWORD),
|
|
]
|
|
|
|
MONITORINFOF_PRIMARY = 1
|
|
primary_res = None
|
|
|
|
def callback(hmon, hdc, rect, data):
|
|
nonlocal primary_res
|
|
mi = MONITORINFO()
|
|
mi.cbSize = ctypes.sizeof(mi)
|
|
ctypes.windll.user32.GetMonitorInfoW(hmon, ctypes.byref(mi))
|
|
if mi.dwFlags & MONITORINFOF_PRIMARY:
|
|
w = mi.rcMonitor.right - mi.rcMonitor.left
|
|
h = mi.rcMonitor.bottom - mi.rcMonitor.top
|
|
primary_res = f"{w}x{h}"
|
|
return True
|
|
|
|
MONITORENUMPROC = ctypes.WINFUNCTYPE(
|
|
ctypes.c_int,
|
|
ctypes.wintypes.HMONITOR,
|
|
ctypes.wintypes.HDC,
|
|
ctypes.POINTER(ctypes.wintypes.RECT),
|
|
ctypes.wintypes.LPARAM,
|
|
)
|
|
ctypes.windll.user32.EnumDisplayMonitors(
|
|
None, None, MONITORENUMPROC(callback), 0
|
|
)
|
|
return primary_res
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _mark_primary(monitors: list[MonitorInfo]) -> None:
|
|
"""Mark the primary display in the monitor list."""
|
|
if not monitors:
|
|
return
|
|
|
|
primary_res = _detect_primary_resolution()
|
|
if primary_res:
|
|
for m in monitors:
|
|
if m.resolution == primary_res:
|
|
m.is_primary = True
|
|
return
|
|
|
|
# Fallback: mark first monitor as primary
|
|
monitors[0].is_primary = True
|
|
|
|
|
|
# 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. Keyed by a stable identity tuple
|
|
# (manufacturer, model, edid_hash) so that hot-plug swaps where the new
|
|
# topology has the same number of monitors but different devices still
|
|
# refresh the cache for the new monitor instead of serving stale capabilities.
|
|
_static_cache: dict[tuple, dict] = {}
|
|
|
|
|
|
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). Trickier than color preset because
|
|
# many monitors (LG ultrawides included) respond to READS but silently
|
|
# drop every WRITE - they implement the register but not the feature.
|
|
# The capability string is the most reliable signal: a monitor that
|
|
# really implements picture mode declares its supported codes under
|
|
# cmds[0xDC]. If 0xDC isn't declared, treat the feature as unsupported
|
|
# to avoid exposing a non-functional select.
|
|
cmds = caps.get("cmds") or {}
|
|
declared = cmds.get(PICTURE_MODE_VCP)
|
|
if declared:
|
|
try:
|
|
mon.vcp.get_vcp_feature(PICTURE_MODE_VCP)
|
|
static["picture_mode_supported"] = True
|
|
static["available_picture_modes"] = [
|
|
{"code": c, "label": PICTURE_MODE_LABELS.get(c, f"Mode {c}")}
|
|
for c in sorted(declared)
|
|
]
|
|
except Exception as e:
|
|
logger.debug("Monitor %d: picture_mode declared but unreadable: %s", monitor_id, e)
|
|
else:
|
|
logger.debug("Monitor %d: picture_mode (VCP 0xDC) not declared in capability string", monitor_id)
|
|
|
|
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
|
|
|
|
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()
|
|
if sbc is None:
|
|
return []
|
|
|
|
monitors = []
|
|
try:
|
|
info_list = sbc.list_monitors_info()
|
|
brightnesses = sbc.get_brightness()
|
|
|
|
# Explicit rediscover wipes the whole cache; otherwise rely on stable
|
|
# per-monitor keys (manufacturer|model|edid_hash) so a hot-plug swap
|
|
# invalidates the entry for the missing monitor automatically.
|
|
if rediscover:
|
|
_static_cache.clear()
|
|
|
|
mc = _load_monitorcontrol()
|
|
ddc_monitors = []
|
|
if mc:
|
|
try:
|
|
ddc_monitors = mc.get_monitors()
|
|
except Exception:
|
|
pass
|
|
|
|
import hashlib
|
|
|
|
seen_keys: set[tuple] = set()
|
|
for i, info in enumerate(info_list):
|
|
name = info.get("name", f"Monitor {i}")
|
|
model = info.get("model", "")
|
|
manufacturer = info.get("manufacturer", "")
|
|
brightness = brightnesses[i] if i < len(brightnesses) else None
|
|
# VCP method monitors support DDC/CI power control
|
|
method = str(info.get("method", "")).lower()
|
|
power_supported = "vcp" in method
|
|
|
|
# Parse resolution from EDID
|
|
edid = info.get("edid", "")
|
|
resolution = _parse_edid_resolution(edid) if edid else None
|
|
|
|
# Stable cache key — EDID hash is unique per physical monitor.
|
|
# Fall back to (manufacturer, model, serial-ish) when EDID is
|
|
# missing, then to the legacy index as a last resort.
|
|
if edid:
|
|
edid_hash = hashlib.blake2b(
|
|
edid.encode("utf-8") if isinstance(edid, str) else bytes(edid),
|
|
digest_size=8,
|
|
).hexdigest()
|
|
cache_key: tuple = ("edid", edid_hash)
|
|
elif manufacturer or model:
|
|
cache_key = ("mm", manufacturer, model, name)
|
|
else:
|
|
cache_key = ("idx", i)
|
|
seen_keys.add(cache_key)
|
|
|
|
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:
|
|
if cache_key not in _static_cache:
|
|
_static_cache[cache_key] = _probe_static_open(mon, mc, i)
|
|
static = _static_cache[cache_key]
|
|
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(cache_key, {})
|
|
|
|
monitors.append(MonitorInfo(
|
|
id=i,
|
|
name=name,
|
|
brightness=brightness,
|
|
power_supported=power_supported,
|
|
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),
|
|
))
|
|
# Evict cache entries for monitors that disappeared from this scan so
|
|
# the next hot-plug of a different monitor with the same identity
|
|
# tuple (e.g. same model) doesn't hit a stale entry first.
|
|
for stale_key in list(_static_cache.keys()):
|
|
if stale_key not in seen_keys:
|
|
_static_cache.pop(stale_key, None)
|
|
except Exception as e:
|
|
logger.error("Failed to enumerate monitors: %s", e)
|
|
|
|
_mark_primary(monitors)
|
|
_monitor_cache = monitors
|
|
_cache_time = time.time()
|
|
return monitors
|
|
|
|
|
|
def get_brightness(monitor_id: int) -> int | None:
|
|
"""Get brightness for a specific monitor."""
|
|
sbc = _load_sbc()
|
|
if sbc is None:
|
|
return None
|
|
try:
|
|
result = sbc.get_brightness(display=monitor_id)
|
|
if isinstance(result, list):
|
|
return result[0] if result else None
|
|
return result
|
|
except Exception as e:
|
|
logger.error("Failed to get brightness for monitor %d: %s", monitor_id, e)
|
|
return None
|
|
|
|
|
|
def set_brightness(monitor_id: int, value: int) -> bool:
|
|
"""Set brightness for a specific monitor (0-100)."""
|
|
sbc = _load_sbc()
|
|
if sbc is None:
|
|
return False
|
|
|
|
value = max(0, min(100, value))
|
|
try:
|
|
sbc.set_brightness(value, display=monitor_id)
|
|
_invalidate_cache()
|
|
return True
|
|
except Exception as e:
|
|
logger.error("Failed to set brightness for monitor %d: %s", monitor_id, e)
|
|
return False
|
|
|
|
|
|
def set_power(monitor_id: int, on: bool) -> bool:
|
|
"""Turn a monitor on or off via DDC/CI."""
|
|
mc = _load_monitorcontrol()
|
|
if mc is None:
|
|
logger.error("monitorcontrol not available for power control")
|
|
return False
|
|
|
|
try:
|
|
ddc_monitors = mc.get_monitors()
|
|
if monitor_id >= len(ddc_monitors):
|
|
logger.error("Monitor %d not found in DDC/CI monitors", monitor_id)
|
|
return False
|
|
|
|
with ddc_monitors[monitor_id] as monitor:
|
|
if on:
|
|
monitor.set_power_mode(mc.PowerMode.on)
|
|
else:
|
|
monitor.set_power_mode(mc.PowerMode.off_soft)
|
|
|
|
_invalidate_cache()
|
|
return True
|
|
except Exception as e:
|
|
logger.error("Failed to set power for monitor %d: %s", monitor_id, e)
|
|
return False
|
|
|
|
|
|
def _verify_after_set(getter, expected, *, retries: int = 3, delay: float = 0.1) -> bool:
|
|
"""Poll a DDC/CI getter to confirm the monitor actually applied a write.
|
|
|
|
DDC/CI writes are fire-and-forget at the protocol level: a successful
|
|
send does not mean the monitor honored the value. Many monitors silently
|
|
drop writes for codes their firmware doesn't really implement (LG's
|
|
ColorPreset / Picture Mode are common offenders). Without this check the
|
|
API would report `success: true` while the monitor sat unchanged.
|
|
|
|
Compares both raw and `.value` forms so enum/int mismatches don't flag a
|
|
spurious failure.
|
|
"""
|
|
expected_int = getattr(expected, "value", expected)
|
|
for _ in range(retries):
|
|
time.sleep(delay)
|
|
try:
|
|
actual = getter()
|
|
except Exception:
|
|
continue
|
|
actual_int = getattr(actual, "value", actual)
|
|
if actual == expected or actual_int == expected_int:
|
|
return True
|
|
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)
|
|
if not _verify_after_set(monitor.get_contrast, value):
|
|
logger.warning("Monitor %d: contrast %d not applied", monitor_id, value)
|
|
return False
|
|
_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)
|
|
# Source switches can briefly disrupt the DDC/CI link; allow a
|
|
# longer settle window before declaring failure.
|
|
if not _verify_after_set(monitor.get_input_source, target, retries=5, delay=0.2):
|
|
logger.warning("Monitor %d: input source %s not applied", monitor_id, source)
|
|
return False
|
|
_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)
|
|
if not _verify_after_set(monitor.get_color_preset, target):
|
|
logger.warning(
|
|
"Monitor %d: color preset %s not applied (monitor silently rejected)",
|
|
monitor_id, preset,
|
|
)
|
|
return False
|
|
_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)
|
|
# Raw VCP read returns (current, maximum) — only compare current.
|
|
def _read_picture_mode():
|
|
current, _ = monitor.vcp.get_vcp_feature(PICTURE_MODE_VCP)
|
|
return current
|
|
if not _verify_after_set(_read_picture_mode, code):
|
|
logger.warning(
|
|
"Monitor %d: picture mode code %d not applied (monitor silently rejected)",
|
|
monitor_id, code,
|
|
)
|
|
return False
|
|
_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
|