Compare commits

...
7 Commits
Author SHA1 Message Date
Hermes Agent d3286ef3b8 Block all keyboard (except ESC) and all mouse input via WH_MOUSE_LL; tagged idle nudge passes through own hook; hint updated 2026-09-18 13:02:58 +02:00
Hermes Agent d16a2db9ae Add periodic invisible mouse nudge (30s) to keep display awake and prevent ESC wake-swallow double-press; log SetThreadExecutionState result 2026-09-18 11:21:28 +02:00
Hermes Agent 0725915317 Keep display awake while running (SetThreadExecutionState) so the monitor does not enter standby and the DDC/CI brightness restore on exit no longer hangs ~30s; bound native DDC calls with a 2s timeout as defense 2026-09-17 22:33:29 +02:00
Hermes Agent d3437f924b Force wmi method on brightness fallback to skip slow DDC/VCP 49-retry 2026-09-16 17:30:43 +02:00
Hermes Agent e61ea172d3 Docs: document hybrid brightness (native DDC/CI + WMI fallback) and rollback to v1-wmi-only 2026-09-16 17:07:10 +02:00
Hermes Agent bca41eadc4 Fix dxva2 arg: pass PHYSICAL_MONITOR array directly (not byref) for GetPhysicalMonitorsFromHMONITOR/DestroyPhysicalMonitors 2026-09-16 17:03:43 +02:00
Hermes Agent d0f6fd8818 Hybrid brightness: native DDC/CI (dxva2) with per-monitor WMI fallback
- BrightnessController enumerates monitors, opens each via dxva2 and reads/writes
  brightness directly (sub-ms); falls back per monitor to screen_brightness_control
  (WMI) for panels without DDC/CI (laptop internal screens)
- native dim sets each monitor to its minimum; restore sets saved value
- tagged v1-wmi-only before this change as safe rollback point
2026-09-16 16:57:18 +02:00
2 changed files with 288 additions and 42 deletions

No files matched your search

+11 -1
View File
@@ -20,7 +20,9 @@ pyinstaller "Black Screen Dim.spec"
Готовый exe — в `dist\Black Screen Dim\`. Готовый exe — в `dist\Black Screen Dim\`.
## Зависимости ## Зависимости
- `screen-brightness-control` — управление яркостью (только Windows) - Яркость — **гибрид**: сначала быстрый нативный DDC/CI (`dxva2.dll`, суб-миллисекунды),
при отсутствии DDC/CI на мониторе (например, встроенный экран ноутбука) — откат на
`screen-brightness-control` (WMI) для этого монитора.
- чёрная заливка экрана — напрямую через Win32 API (ctypes, входит в стандартную библиотеку), pygame больше не нужен - чёрная заливка экрана — напрямую через Win32 API (ctypes, входит в стандартную библиотеку), pygame больше не нужен
- лог пишется в `%TEMP%\black_screen_dim.log` - лог пишется в `%TEMP%\black_screen_dim.log`
@@ -30,3 +32,11 @@ pyinstaller "Black Screen Dim.spec"
> При нескольких мониторах чёрный экран накрывает **весь виртуальный рабочий стол** > При нескольких мониторах чёрный экран накрывает **весь виртуальный рабочий стол**
> (все мониторы сразу, включая те, что слева/сверху от основного), а яркость > (все мониторы сразу, включая те, что слева/сверху от основного), а яркость
> гасится и восстанавливается **на каждом** мониторе по отдельности. > гасится и восстанавливается **на каждом** мониторе по отдельности.
## Откат на WMI-только версию
Если гибридная яркость работает некорректно, есть два варианта вернуться к
проверенной версии (только WMI):
- **Быстрый (на ноуте):** переименуй `black_screen_dim.py.bak` обратно в `black_screen_dim.py`
и пересобери exe.
- **Через git:** `git -C <repo> checkout v1-wmi-only -- black_screen_dim.py`
(тег `v1-wmi-only` указывает на последний стабильный коммит до гибрида).
+277 -41
View File
@@ -45,34 +45,178 @@ def setup_logging():
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
# Brightness — every monitor # Brightness — every monitor
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
def get_all_brightness(): # --------------------------------------------------------------------------
try: # Brightness — hybrid: fast native DDC/CI (dxva2) with per-monitor WMI fallback
vals = sbc.get_brightness() # --------------------------------------------------------------------------
except Exception as e: class BrightnessController:
LOG.warning("get_brightness failed: %r", e) """Controls brightness on every monitor. Prefers the native DDC/CI API
return None (dxva2.dll, sub-ms) and falls back per monitor to screen_brightness_control
if isinstance(vals, list): (WMI) for panels that don't expose DDC/CI (e.g. laptop internal screens)."""
return list(vals) if vals else None
return [vals]
PHYSICAL_MONITOR = None # ctypes struct, set on Windows
def set_all_brightness(value): def __init__(self):
try: self.targets = [] # [("native", handle, (lo,cur,hi)), ("wmi", idx)]
sbc.set_brightness(int(value)) self.saved = None
return True
except Exception as e:
LOG.warning("set_brightness(%s) failed: %r", value, e)
return False
@staticmethod
def _native():
return sys.platform == "win32" and hasattr(ctypes.windll, "dxva2")
def restore_all_brightness(original): def _open_physical(self, hmon):
if not original: dx = ctypes.windll.dxva2
return arr = (BrightnessController.PHYSICAL_MONITOR * 1)()
for i, b in enumerate(original): if not dx.GetPhysicalMonitorsFromHMONITOR(int(hmon), 1, arr):
return None
return arr[0].hPhysicalMonitor
def _read_native(self, handle):
dx = ctypes.windll.dxva2
lo = wt.DWORD(); cur = wt.DWORD(); hi = wt.DWORD()
if not dx.GetMonitorBrightness(int(handle), ctypes.byref(lo), ctypes.byref(cur), ctypes.byref(hi)):
return None
return (lo.value, cur.value, hi.value)
def _write_native(self, handle, value):
try: try:
sbc.set_brightness(int(b), display=i) dx = ctypes.windll.dxva2
res = {}
def _t():
try:
res["v"] = bool(
dx.SetMonitorBrightness(int(handle), wt.DWORD(int(value)))
)
except Exception as e:
res["e"] = e
th = threading.Thread(target=_t, daemon=True)
th.start()
th.join(timeout=2.0)
if th.is_alive():
LOG.warning("native SetMonitorBrightness timed out (monitor in standby?)")
return False
if "e" in res:
LOG.warning("native write failed: %r", res["e"])
return False
return res.get("v", False)
except Exception as e: except Exception as e:
LOG.warning("restore brightness monitor %d failed: %r", i, e) LOG.warning("native write error: %r", e)
return False
def _close_native(self, handle):
try:
dx = ctypes.windll.dxva2
arr = (BrightnessController.PHYSICAL_MONITOR * 1)()
arr[0].hPhysicalMonitor = handle
res = {}
def _t():
try:
res["v"] = dx.DestroyPhysicalMonitors(1, arr)
except Exception as e:
res["e"] = e
th = threading.Thread(target=_t, daemon=True)
th.start()
th.join(timeout=2.0)
if th.is_alive():
return
if "e" in res:
LOG.warning("native close failed: %r", res["e"])
except Exception as e:
LOG.warning("native close error: %r", e)
def _build(self):
self.targets = []
if self._native():
for i, hmon in enumerate(enum_monitor_handles()):
handle = None
try:
handle = self._open_physical(hmon)
except Exception as e:
LOG.warning("native open failed: %r", e)
handle = None
if handle is not None:
try:
r = self._read_native(handle)
except Exception as e:
LOG.warning("native read failed: %r", e)
r = None
if r is not None:
self.targets.append(("native", handle, r))
continue
try:
self._close_native(handle)
except Exception:
pass
self.targets.append(("wmi", i))
if not self.targets:
LOG.warning("no monitors via native; brightness control disabled")
LOG.info("brightness targets: %s", [t[0] for t in self.targets])
def save(self):
self._build()
values = []
for t in self.targets:
if t[0] == "native":
_, handle, (lo, cur, hi) = t
values.append(cur)
else:
idx = t[1]
try:
v = sbc.get_brightness(display=idx, method="wmi")
except Exception as e:
LOG.warning("wmi get display %d failed: %r", idx, e)
v = None
if isinstance(v, list):
v = v[0] if v else None
values.append(v)
self.saved = values
LOG.info("saved brightness: %s", values)
return values
def dim(self):
for t in self.targets:
if t[0] == "native":
_, handle, (lo, cur, hi) = t
ok = self._write_native(handle, lo)
LOG.info("native dim -> %s (%s)", lo, "ok" if ok else "FAIL")
else:
idx = t[1]
try:
sbc.set_brightness(0, display=idx, method="wmi")
LOG.info("wmi dim display %d", idx)
except Exception as e:
LOG.warning("wmi dim display %d failed: %r", idx, e)
def restore(self):
if self.saved is None:
self.close()
return
for t, val in zip(self.targets, self.saved):
if val is None:
continue
if t[0] == "native":
_, handle, _ = t
ok = self._write_native(handle, val)
LOG.info("native restore %s (%s)", val, "ok" if ok else "FAIL")
else:
idx = t[1]
try:
sbc.set_brightness(int(val), display=idx, method="wmi")
LOG.info("wmi restore display %d -> %s", idx, val)
except Exception as e:
LOG.warning("wmi restore display %d failed: %r", idx, e)
self.close()
def close(self):
for t in self.targets:
if t[0] == "native":
try:
self._close_native(t[1])
except Exception:
pass
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
@@ -101,11 +245,16 @@ if sys.platform == "win32":
WM_KEYDOWN_LL = 0x0100 WM_KEYDOWN_LL = 0x0100
VK_ESCAPE = 0x1B VK_ESCAPE = 0x1B
WH_KEYBOARD_LL = 13 WH_KEYBOARD_LL = 13
WH_MOUSE_LL = 14
MOUSE_NUDGE_TAG = 0x42424242
DT_CENTER = 0x00000001 DT_CENTER = 0x00000001
DT_VCENTER = 0x00000004 DT_VCENTER = 0x00000004
DT_SINGLELINE = 0x00000020 DT_SINGLELINE = 0x00000020
TRANSPARENT = 1 TRANSPARENT = 1
DEFAULT_GUI_FONT = 17 DEFAULT_GUI_FONT = 17
ES_CONTINUOUS = 0x80000000
ES_SYSTEM_REQUIRED = 0x00000001
ES_DISPLAY_REQUIRED = 0x00000002
LRESULT = ctypes.c_int64 LRESULT = ctypes.c_int64
HMONITOR = ctypes.c_void_p HMONITOR = ctypes.c_void_p
@@ -155,6 +304,15 @@ if sys.platform == "win32":
("dwExtraInfo", ULONG_PTR), ("dwExtraInfo", ULONG_PTR),
] ]
class MSLLHOOKSTRUCT(ctypes.Structure):
_fields_ = [
("pt", wt.POINT),
("mouseData", wt.DWORD),
("flags", wt.DWORD),
("time", wt.DWORD),
("dwExtraInfo", ULONG_PTR),
]
# --- prototypes (64-bit safe) --- # --- prototypes (64-bit safe) ---
user32.EnumDisplayMonitors.argtypes = [wt.HDC, ctypes.POINTER(wt.RECT), MONITORENUMPROC, wt.LPARAM] user32.EnumDisplayMonitors.argtypes = [wt.HDC, ctypes.POINTER(wt.RECT), MONITORENUMPROC, wt.LPARAM]
user32.EnumDisplayMonitors.restype = wt.BOOL user32.EnumDisplayMonitors.restype = wt.BOOL
@@ -201,6 +359,8 @@ if sys.platform == "win32":
user32.DestroyWindow.restype = wt.BOOL user32.DestroyWindow.restype = wt.BOOL
user32.ShowCursor.argtypes = [wt.BOOL] user32.ShowCursor.argtypes = [wt.BOOL]
user32.ShowCursor.restype = wt.INT user32.ShowCursor.restype = wt.INT
user32.mouse_event.argtypes = [wt.DWORD, wt.DWORD, wt.DWORD, wt.DWORD, ctypes.c_void_p]
user32.mouse_event.restype = None
user32.DefWindowProcW.argtypes = [wt.HWND, wt.UINT, wt.WPARAM, wt.LPARAM] user32.DefWindowProcW.argtypes = [wt.HWND, wt.UINT, wt.WPARAM, wt.LPARAM]
user32.DefWindowProcW.restype = LRESULT user32.DefWindowProcW.restype = LRESULT
user32.SetProcessDPIAware.argtypes = [] user32.SetProcessDPIAware.argtypes = []
@@ -221,11 +381,38 @@ if sys.platform == "win32":
kernel32.GetModuleHandleW.restype = wt.HINSTANCE kernel32.GetModuleHandleW.restype = wt.HINSTANCE
kernel32.GetLastError.argtypes = [] kernel32.GetLastError.argtypes = []
kernel32.GetLastError.restype = wt.DWORD kernel32.GetLastError.restype = wt.DWORD
kernel32.SetThreadExecutionState.argtypes = [wt.DWORD]
kernel32.SetThreadExecutionState.restype = wt.DWORD
# --- native DDC/CI brightness (dxva2) ---
class PHYSICAL_MONITOR(ctypes.Structure):
_fields_ = [
("hPhysicalMonitor", wt.HANDLE),
("szPhysicalMonitorDescription", ctypes.c_wchar * 128),
]
try:
dx = ctypes.windll.dxva2
dx.GetPhysicalMonitorsFromHMONITOR.argtypes = [
HMONITOR, wt.DWORD, ctypes.POINTER(PHYSICAL_MONITOR)]
dx.GetPhysicalMonitorsFromHMONITOR.restype = wt.BOOL
dx.GetMonitorBrightness.argtypes = [
wt.HANDLE, ctypes.POINTER(wt.DWORD),
ctypes.POINTER(wt.DWORD), ctypes.POINTER(wt.DWORD)]
dx.GetMonitorBrightness.restype = wt.BOOL
dx.SetMonitorBrightness.argtypes = [wt.HANDLE, wt.DWORD]
dx.SetMonitorBrightness.restype = wt.BOOL
dx.DestroyPhysicalMonitors.argtypes = [
wt.DWORD, ctypes.POINTER(PHYSICAL_MONITOR)]
dx.DestroyPhysicalMonitors.restype = wt.BOOL
BrightnessController.PHYSICAL_MONITOR = PHYSICAL_MONITOR
except Exception as e:
LOG.warning("dxva2 init failed: %r", e)
_hwnds = [] _hwnds = []
_hint_active = True _hint_active = True
_hook_id = None _hook_id = None
_hint_text = "ESC - exit" _hint_text = "ESC - exit | mouse/keyboard locked"
def enum_monitor_rects(): def enum_monitor_rects():
rects = [] rects = []
@@ -238,6 +425,16 @@ if sys.platform == "win32":
user32.EnumDisplayMonitors(None, None, MONITORENUMPROC(cb), 0) user32.EnumDisplayMonitors(None, None, MONITORENUMPROC(cb), 0)
return rects return rects
def enum_monitor_handles():
handles = []
def cb(hmon, hdc, lprect, lparam):
handles.append(hmon)
return True
user32.EnumDisplayMonitors(None, None, MONITORENUMPROC(cb), 0)
return handles
def _wndproc(hwnd, msg, wparam, lparam): def _wndproc(hwnd, msg, wparam, lparam):
global _hint_active global _hint_active
if msg == WM_PAINT: if msg == WM_PAINT:
@@ -270,15 +467,28 @@ if sys.platform == "win32":
return user32.DefWindowProcW(hwnd, msg, wparam, lparam) return user32.DefWindowProcW(hwnd, msg, wparam, lparam)
def _low_level_keyboard_proc(nCode, wParam, lParam): def _low_level_keyboard_proc(nCode, wParam, lParam):
if nCode >= 0 and wParam == WM_KEYDOWN_LL: if nCode >= 0:
kb = ctypes.cast(lParam, ctypes.POINTER(KBDLLHOOKSTRUCT)).contents kb = ctypes.cast(lParam, ctypes.POINTER(KBDLLHOOKSTRUCT)).contents
if kb.vkCode == VK_ESCAPE: if kb.vkCode == VK_ESCAPE and wParam == WM_KEYDOWN_LL:
LOG.info("ESC pressed -> exit") LOG.info("ESC pressed -> exit")
user32.PostQuitMessage(0) user32.PostQuitMessage(0)
return 1 # block ALL keyboard input while the overlay is up
return user32.CallNextHookEx(_hook_id, nCode, wParam, lParam) return user32.CallNextHookEx(_hook_id, nCode, wParam, lParam)
_mouse_hook_id = None
def _low_level_mouse_proc(nCode, wParam, lParam):
if nCode >= 0:
ms = ctypes.cast(lParam, ctypes.POINTER(MSLLHOOKSTRUCT)).contents
# allow only our own idle-keepalive nudge (tagged dwExtraInfo);
# block every real mouse event (moves, clicks, wheel)
if int(ms.dwExtraInfo) == MOUSE_NUDGE_TAG:
return user32.CallNextHookEx(_mouse_hook_id, nCode, wParam, lParam)
return 1
return user32.CallNextHookEx(_mouse_hook_id, nCode, wParam, lParam)
def run_overlay(): def run_overlay():
global _hwnds, _hook_id global _hwnds, _hook_id, _mouse_hook_id
# Per-monitor V2 DPI awareness: device-coord rects map 1:1 to each # Per-monitor V2 DPI awareness: device-coord rects map 1:1 to each
# monitor regardless of mixed DPI / where the primary sits. # monitor regardless of mixed DPI / where the primary sits.
if shcore is not None: if shcore is not None:
@@ -336,11 +546,39 @@ if sys.platform == "win32":
for _ in range(12): for _ in range(12):
if user32.ShowCursor(False) < 0: if user32.ShowCursor(False) < 0:
break break
# global ESC hook # global ESC hook (also blocks all other keyboard input)
kb_proc = HOOKPROC(_low_level_keyboard_proc) kb_proc = HOOKPROC(_low_level_keyboard_proc)
_hook_id = user32.SetWindowsHookExW(WH_KEYBOARD_LL, kb_proc, hinst, 0) _hook_id = user32.SetWindowsHookExW(WH_KEYBOARD_LL, kb_proc, hinst, 0)
if not _hook_id: if not _hook_id:
LOG.warning("SetWindowsHookExW failed: %d", kernel32.GetLastError()) LOG.warning("SetWindowsHookExW failed: %d", kernel32.GetLastError())
# global mouse hook: block all real mouse input
mouse_proc = HOOKPROC(_low_level_mouse_proc)
_mouse_hook_id = user32.SetWindowsHookExW(WH_MOUSE_LL, mouse_proc, hinst, 0)
if not _mouse_hook_id:
LOG.warning("mouse hook failed: %d", kernel32.GetLastError())
# keep display awake while running (prevents monitor standby, which would
# make ESC's first press get swallowed by the wake and a ~30s DDC hang)
sees = kernel32.SetThreadExecutionState(
ES_CONTINUOUS | ES_DISPLAY_REQUIRED | ES_SYSTEM_REQUIRED
)
LOG.info("SetThreadExecutionState -> %s", sees)
# belt-and-suspenders: emit a tiny (invisible) mouse move every 30s so the
# OS and any monitor hardware idle timer never trip during long idle.
_activity_stop = threading.Event()
def _activity():
while not _activity_stop.wait(30):
try:
tag = ctypes.c_void_p(MOUSE_NUDGE_TAG)
user32.mouse_event(0x0001, 1, 0, 0, tag)
user32.mouse_event(0x0001, 0xFFFFFFFF, 0, 0, tag)
except Exception:
pass
at = threading.Thread(target=_activity, daemon=True)
at.start()
msg = wt.MSG() msg = wt.MSG()
while user32.GetMessageW(ctypes.byref(msg), 0, 0, 0) > 0: while user32.GetMessageW(ctypes.byref(msg), 0, 0, 0) > 0:
@@ -349,6 +587,12 @@ if sys.platform == "win32":
if _hook_id: if _hook_id:
user32.UnhookWindowsHookEx(_hook_id) user32.UnhookWindowsHookEx(_hook_id)
if _mouse_hook_id:
user32.UnhookWindowsHookEx(_mouse_hook_id)
_mouse_hook_id = None
_activity_stop.set()
at.join(timeout=1)
kernel32.SetThreadExecutionState(ES_CONTINUOUS) # allow display sleep again
for h in _hwnds: for h in _hwnds:
user32.DestroyWindow(h) user32.DestroyWindow(h)
# restore cursor # restore cursor
@@ -357,15 +601,9 @@ if sys.platform == "win32":
break break
def _dim_and_save(): def _dim_and_save(bc):
"""Save current brightness and dim all monitors in the background so the bc.save()
black overlay can appear instantly; the dimming itself is invisible.""" bc.dim()
global _original_brightness
orig = get_all_brightness()
_original_brightness = orig
LOG.info("saved brightness: %s", orig)
if orig is not None:
set_all_brightness(0)
def main(): def main():
@@ -373,7 +611,8 @@ def main():
LOG.info("starting Black Screen Dim") LOG.info("starting Black Screen Dim")
# Show the black overlay FIRST so the screen goes black immediately. # Show the black overlay FIRST so the screen goes black immediately.
# Brightness saving/dimming runs in the background, invisible behind it. # Brightness saving/dimming runs in the background, invisible behind it.
dim_thread = threading.Thread(target=_dim_and_save, daemon=True) bc = BrightnessController()
dim_thread = threading.Thread(target=_dim_and_save, args=(bc,), daemon=True)
dim_thread.start() dim_thread.start()
try: try:
if sys.platform == "win32": if sys.platform == "win32":
@@ -384,10 +623,7 @@ def main():
LOG.exception("overlay error: %r", e) LOG.exception("overlay error: %r", e)
finally: finally:
dim_thread.join(timeout=10) dim_thread.join(timeout=10)
orig = _original_brightness bc.restore()
if orig is not None:
restore_all_brightness(orig)
LOG.info("restored brightness: %s", orig)
LOG.info("exiting") LOG.info("exiting")