Compare commits
7
Commits
v1-wmi-only
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d3286ef3b8 | ||
|
|
d16a2db9ae | ||
|
|
0725915317 | ||
|
|
d3437f924b | ||
|
|
e61ea172d3 | ||
|
|
bca41eadc4 | ||
|
|
d0f6fd8818 |
No files matched your search
@@ -20,7 +20,9 @@ pyinstaller "Black Screen Dim.spec"
|
||||
Готовый exe — в `dist\Black Screen Dim\`.
|
||||
|
||||
## Зависимости
|
||||
- `screen-brightness-control` — управление яркостью (только Windows)
|
||||
- Яркость — **гибрид**: сначала быстрый нативный DDC/CI (`dxva2.dll`, суб-миллисекунды),
|
||||
при отсутствии DDC/CI на мониторе (например, встроенный экран ноутбука) — откат на
|
||||
`screen-brightness-control` (WMI) для этого монитора.
|
||||
- чёрная заливка экрана — напрямую через Win32 API (ctypes, входит в стандартную библиотеку), pygame больше не нужен
|
||||
- лог пишется в `%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
@@ -45,34 +45,178 @@ def setup_logging():
|
||||
# --------------------------------------------------------------------------
|
||||
# Brightness — every monitor
|
||||
# --------------------------------------------------------------------------
|
||||
def get_all_brightness():
|
||||
try:
|
||||
vals = sbc.get_brightness()
|
||||
except Exception as e:
|
||||
LOG.warning("get_brightness failed: %r", e)
|
||||
return None
|
||||
if isinstance(vals, list):
|
||||
return list(vals) if vals else None
|
||||
return [vals]
|
||||
# --------------------------------------------------------------------------
|
||||
# Brightness — hybrid: fast native DDC/CI (dxva2) with per-monitor WMI fallback
|
||||
# --------------------------------------------------------------------------
|
||||
class BrightnessController:
|
||||
"""Controls brightness on every monitor. Prefers the native DDC/CI API
|
||||
(dxva2.dll, sub-ms) and falls back per monitor to screen_brightness_control
|
||||
(WMI) for panels that don't expose DDC/CI (e.g. laptop internal screens)."""
|
||||
|
||||
PHYSICAL_MONITOR = None # ctypes struct, set on Windows
|
||||
|
||||
def set_all_brightness(value):
|
||||
try:
|
||||
sbc.set_brightness(int(value))
|
||||
return True
|
||||
except Exception as e:
|
||||
LOG.warning("set_brightness(%s) failed: %r", value, e)
|
||||
return False
|
||||
def __init__(self):
|
||||
self.targets = [] # [("native", handle, (lo,cur,hi)), ("wmi", idx)]
|
||||
self.saved = None
|
||||
|
||||
@staticmethod
|
||||
def _native():
|
||||
return sys.platform == "win32" and hasattr(ctypes.windll, "dxva2")
|
||||
|
||||
def restore_all_brightness(original):
|
||||
if not original:
|
||||
return
|
||||
for i, b in enumerate(original):
|
||||
def _open_physical(self, hmon):
|
||||
dx = ctypes.windll.dxva2
|
||||
arr = (BrightnessController.PHYSICAL_MONITOR * 1)()
|
||||
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:
|
||||
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:
|
||||
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
|
||||
VK_ESCAPE = 0x1B
|
||||
WH_KEYBOARD_LL = 13
|
||||
WH_MOUSE_LL = 14
|
||||
MOUSE_NUDGE_TAG = 0x42424242
|
||||
DT_CENTER = 0x00000001
|
||||
DT_VCENTER = 0x00000004
|
||||
DT_SINGLELINE = 0x00000020
|
||||
TRANSPARENT = 1
|
||||
DEFAULT_GUI_FONT = 17
|
||||
ES_CONTINUOUS = 0x80000000
|
||||
ES_SYSTEM_REQUIRED = 0x00000001
|
||||
ES_DISPLAY_REQUIRED = 0x00000002
|
||||
|
||||
LRESULT = ctypes.c_int64
|
||||
HMONITOR = ctypes.c_void_p
|
||||
@@ -155,6 +304,15 @@ if sys.platform == "win32":
|
||||
("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) ---
|
||||
user32.EnumDisplayMonitors.argtypes = [wt.HDC, ctypes.POINTER(wt.RECT), MONITORENUMPROC, wt.LPARAM]
|
||||
user32.EnumDisplayMonitors.restype = wt.BOOL
|
||||
@@ -201,6 +359,8 @@ if sys.platform == "win32":
|
||||
user32.DestroyWindow.restype = wt.BOOL
|
||||
user32.ShowCursor.argtypes = [wt.BOOL]
|
||||
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.restype = LRESULT
|
||||
user32.SetProcessDPIAware.argtypes = []
|
||||
@@ -221,11 +381,38 @@ if sys.platform == "win32":
|
||||
kernel32.GetModuleHandleW.restype = wt.HINSTANCE
|
||||
kernel32.GetLastError.argtypes = []
|
||||
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 = []
|
||||
_hint_active = True
|
||||
_hook_id = None
|
||||
_hint_text = "ESC - exit"
|
||||
_hint_text = "ESC - exit | mouse/keyboard locked"
|
||||
|
||||
def enum_monitor_rects():
|
||||
rects = []
|
||||
@@ -238,6 +425,16 @@ if sys.platform == "win32":
|
||||
user32.EnumDisplayMonitors(None, None, MONITORENUMPROC(cb), 0)
|
||||
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):
|
||||
global _hint_active
|
||||
if msg == WM_PAINT:
|
||||
@@ -270,15 +467,28 @@ if sys.platform == "win32":
|
||||
return user32.DefWindowProcW(hwnd, msg, 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
|
||||
if kb.vkCode == VK_ESCAPE:
|
||||
if kb.vkCode == VK_ESCAPE and wParam == WM_KEYDOWN_LL:
|
||||
LOG.info("ESC pressed -> exit")
|
||||
user32.PostQuitMessage(0)
|
||||
return 1 # block ALL keyboard input while the overlay is up
|
||||
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():
|
||||
global _hwnds, _hook_id
|
||||
global _hwnds, _hook_id, _mouse_hook_id
|
||||
# Per-monitor V2 DPI awareness: device-coord rects map 1:1 to each
|
||||
# monitor regardless of mixed DPI / where the primary sits.
|
||||
if shcore is not None:
|
||||
@@ -336,11 +546,39 @@ if sys.platform == "win32":
|
||||
for _ in range(12):
|
||||
if user32.ShowCursor(False) < 0:
|
||||
break
|
||||
# global ESC hook
|
||||
# global ESC hook (also blocks all other keyboard input)
|
||||
kb_proc = HOOKPROC(_low_level_keyboard_proc)
|
||||
_hook_id = user32.SetWindowsHookExW(WH_KEYBOARD_LL, kb_proc, hinst, 0)
|
||||
if not _hook_id:
|
||||
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()
|
||||
while user32.GetMessageW(ctypes.byref(msg), 0, 0, 0) > 0:
|
||||
@@ -349,6 +587,12 @@ if sys.platform == "win32":
|
||||
|
||||
if _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:
|
||||
user32.DestroyWindow(h)
|
||||
# restore cursor
|
||||
@@ -357,15 +601,9 @@ if sys.platform == "win32":
|
||||
break
|
||||
|
||||
|
||||
def _dim_and_save():
|
||||
"""Save current brightness and dim all monitors in the background so the
|
||||
black overlay can appear instantly; the dimming itself is invisible."""
|
||||
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 _dim_and_save(bc):
|
||||
bc.save()
|
||||
bc.dim()
|
||||
|
||||
|
||||
def main():
|
||||
@@ -373,7 +611,8 @@ def main():
|
||||
LOG.info("starting Black Screen Dim")
|
||||
# Show the black overlay FIRST so the screen goes black immediately.
|
||||
# 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()
|
||||
try:
|
||||
if sys.platform == "win32":
|
||||
@@ -384,10 +623,7 @@ def main():
|
||||
LOG.exception("overlay error: %r", e)
|
||||
finally:
|
||||
dim_thread.join(timeout=10)
|
||||
orig = _original_brightness
|
||||
if orig is not None:
|
||||
restore_all_brightness(orig)
|
||||
LOG.info("restored brightness: %s", orig)
|
||||
bc.restore()
|
||||
LOG.info("exiting")
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user