A phantom-input aware idle watchdog for Windows. It turns the monitor off, suspends and hibernates on a real user-idle clock, ignoring HID devices that keep spamming input reports.
No SDK required — it is a single C# file compiled with the csc.exe that ships with every
Windows 10/11 installation.
离开 5 分钟 → 关显示器(有音视频在放则推迟)→ 10 分钟 → 睡眠 S3 → 30 分钟 → 休眠 S4
Status: v1.1.2, verified 2026-10-28 on Windows 11 build 26300 (x64, .NET Framework 4.8): monitor off at 15 s, S3 at 20 s, self-wake after ~65 s, idle clock reset, process alive throughout.
Some USB HID devices send an input report several times per second even when nobody touches anything:
- water-cooling / AIO LCD controllers that stream telemetry to the PC,
- motherboard / GPU vendor dashboards, RGB and fan controllers,
- macro pads, KVM switches, virtual HID emulators (
VID_1D6B= Linux Foundation virtual HID), - some vendor utilities that "keep the device alive" through a virtual consumer-control device.
Windows counts every one of those reports as user activity. GetLastInputInfo() therefore never
ages, and the built-in timers (screen off, sleep, hibernate) never fire — the monitor stays on
all night and the PC never sleeps, no matter what you configure in Power Options.
powercfg /requests does not show this: it is input activity, not a power request. That is why
the usual "something is holding a power request" advice does not help here.
SleepGuard solves it by keeping its own idle clock:
| Signal | Counted as real input? |
|---|---|
| Mouse / keyboard / pen / touch / precision touchpad raw input from a normal device | yes, resets the clock |
Raw input from a device whose name matches a phantom pattern (default VID_1D6B&PID_0122) |
no — ignored |
| Anything else (vendor HID, sensor, consumer control) | no — it never reaches WM_INPUT as mouse/keyboard |
| Audio / video currently playing | monitor-off is postponed (sleep/hibernate still happen) |
| Real idle | Action | Notes |
|---|---|---|
≥ --display (default 300 s) |
Turn the monitor off (SC_MONITORPOWER) |
Postponed while audio/video is playing; re-sent every 45 s while still idle; --pause-app is paused while dark |
≥ --sleep (default 600 s) |
Suspend to RAM (S3) | Any real input wakes it instantly |
≥ --hibernate (default 1800 s) |
Hibernate (S4) | Implemented as sleep first, then hibernate: a resume timer wakes the machine at --hibernate and it hibernates |
| any time | Real keyboard/mouse | Clock resets, display wakes |
Audio playing does not stop sleep/hibernate by design (music alone should not keep the PC awake). Only the monitor-off step is postponed — the "watching a video / listening to a podcast, the screen would otherwise go black" exception.
git clone https://github.com/arthur-aicoding/SleepGuard.git
cd SleepGuard
powershell -ExecutionPolicy Bypass -File .\build.ps1
# elevated PowerShell (registers a logon task that runs the watchdog):
.\install.ps1 -Display 300 -Sleep 600 -Hibernate 1800install.ps1 builds if needed, registers the scheduled task SleepGuard (at logon, interactive
session, highest privileges, no time limit, restart on failure) and starts it through
launch-sleepguard.ps1 — the launcher re-creates the guard via WMI, which is required for
SetSuspendState to work (see Gotchas below).
Log file: %LOCALAPPDATA%\SleepGuard\sleep-guard.log
Get-Content "$env:LOCALAPPDATA\SleepGuard\sleep-guard.log" -Tail 30| Argument | Default | Meaning |
|---|---|---|
--display <sec> |
300 |
Turn the monitor off after N seconds of real idle (0 = never) |
--sleep <sec> |
600 |
Suspend to RAM after N seconds of real idle (0 = never) |
--hibernate <sec> |
0 |
Hibernate after N seconds of total idle; must be > --sleep (0 = never) |
--phantom <a,b> |
VID_1D6B&PID_0122 |
Comma-separated device-name substrings to ignore (case-insensitive, matches anywhere in the raw-input device name) |
--phantom-audio <a,b> |
网易UU远程,UU远程 |
Comma-separated audio-session display names that must not count as "media is playing" |
--pause-app <a,b> |
HiMOS (set in install.ps1) |
Comma-separated process names that write phantom input; paused while the screens are dark and resumed on the first real input. Handled by the SleepGuardAgent task, because the guard cannot see those processes itself on this machine |
--pause-request <path> |
log directory | Where the guard writes pause / resume requests for the agent (%LOCALAPPDATA%\SleepGuard\pause-request.txt in production) |
--audio-debug |
off | Log every audio session state and a 30 s heartbeat (noisy; diagnosis only) |
--log <path> |
%LOCALAPPDATA%\SleepGuard\sleep-guard.log |
Log file (rotated to .old at 1 MB) |
--wake <sec> |
0 |
Test only: arm a resume timer before suspending so the machine wakes itself |
--hib-wake <sec> |
0 |
Test only: same for the hibernate step |
build.ps1 builds src\SleepGuard.cs -> dist\SleepGuard.exe (/target:exe, see gotchas)
install.ps1 registers + starts the logon task (elevated)
uninstall.ps1 removes the task / files
launch-sleepguard.ps1 task action: re-creates the guard through WMI so it can suspend
src\SleepGuard.cs the watchdog (single file, no dependencies)
dist\ build output (dist\tools\ for the probes)
tools\ standalone diagnostic probes + build-tools.ps1 + README.md
RawProbe.cs raw-input counters per device / per usage
KeyProbe.cs GetAsyncKeyState polling, per launch context
HookProbe.cs low-level hook delivery test
AllocProbe.cs does AllocConsole() rescue raw input in a winexe build
SendMove.cs injects absolute cursor moves
AudioSessions.cs dumps CoreAudio sessions per endpoint
AudioTest.cs loops a system WAV so a real audio session exists
PlaySound.cs plays one short WAV
SuspendTest.cs token privileges + SetSuspendState, the task-vs-WMI experiment
inject-*.ps1 input injection helpers
quiet-probe.ps1 one-off "who is refreshing the idle timer" probe
sleep-watch.ps1 long sampler (idle, cursor, power requests)
tests\ reproducible end-to-end scripts + README.md
_common.ps1 helpers: stop/restore production guard, start test guard via WMI
run-input-test.ps1 injected input must reset the idle clock (safe)
run-display-test.ps1 monitor really goes dark (screen flashes)
run-sleep-test.ps1 real S3 + self wake (-Force, machine sleeps)
run-full-selftest.ps1 build + the three stages, PASS/FAIL summary
run-monitor-test.ps1 DDC/CI: the panel really stays dark and wakes on input
run-pause-test.ps1 pause-agent protocol: guard request -> agent suspends/resumes HiMOS
sleepguard-agent.ps1 the pause agent itself (registered as the SleepGuardAgent task)
docs\ testing.md, troubleshooting.md, changelog.md, case-study.md, ci-workflow.yml
.\tests\run-full-selftest.ps1 # build + input + monitor-off (never sleeps)
.\tests\run-full-selftest.ps1 -Force # also suspend/resume: the machine really sleeps
.\tests\run-monitor-test.ps1 # panel really stays dark, real input wakes it (DDC/CI)
.\tests\run-pause-test.ps1 # guard <-> agent pause/resume handshake (HiMOS)Each stage stops the production guard, starts a throw-away one with short thresholds, checks the log (and Kernel-Power events for the suspend stage), then restores the production guard. Details, expected timings and failure triage: docs/testing.md. Repro commands for the individual tools: tools/README.md.
Quick manual smoke test (does not install anything):
.\dist\SleepGuard.exe --display 15 --sleep 0 --hibernate 0 --audio-debug --log .\selftest.log
# screen goes dark after ~15 s; Ctrl+C / any input to stop- Hidden window +
RegisterRawInputDevices(..., RIDEV_INPUTSINK)for mouse, keyboard, pen, touch and precision touchpad.WM_INPUTcarries the device name; a device matching--phantomis ignored. Only real events reset the clock. A 20 msGetCursorPospoll is the second channel. - A 1 s timer tick compares the clock against the thresholds. The clock uses
DateTime.UtcNow(wall clock), becauseStopwatch/QPC may not advance across S3/S4 — that is also how the process knows whether it really slept when it resumes. - Monitor off:
SendMessageTimeout(HWND_BROADCAST, WM_SYSCOMMAND, SC_MONITORPOWER, 2)plus a DDC/CI DPMS-off (MCCS VCP 0xD6 = 4) on every physical monitor. TheSC_MONITORPOWERblank alone is undone by the next input event — and the phantom HID fires ~3 reports/s — so the panel lit up again a moment after going dark. DDC standby is handled by the monitor itself and stays until the guard asks forDPMS on(which happens on the next real input, after resume from sleep, and from the crash/exit paths so the screen can never be left dark).--no-ddcfalls back to the OS-only behaviour; monitors with DDC/CI disabled in their OSD reportpower=unreadableand are simply left toSC_MONITORPOWER. - Suspend:
SetSuspendState(FALSE, FALSE, FALSE)after enablingSeShutdownPrivilege.SetSuspendStatecan silently return success while nothing happens (a power request blocked it), so SleepGuard measures how long the call took and retries every 30 s if it returned in under 2 s without sleeping. - Hibernate chain: before suspending it arms a waitable timer for
--hibernate - --sleepseconds withfResume = TRUE. The machine wakes, sees the elapsed wall time has reached the total idle target and callsSetSuspendState(TRUE, ...). Real input in the meantime cancels the hibernate. - Audio exception: CoreAudio
IAudioSessionManager2session enumeration on the default render endpoint; anyAudioSessionStateActivesession postpones monitor-off. Sessions whose display name matches--phantom-audioare skipped. Sleep/hibernate timing is untouched. - Pause agent: when the screens go dark the guard writes
pause+ process names topause-request.txt; theSleepGuardAgentlogon task (plain Windows PowerShell) suspends those processes and writes the real pids topause-state.txt. The first real input makes the guard writeresume, and the agent resumes them. This stops the phantom HID writer itself (--pause-app HiMOS) so the blank stays dark without DDC tricks. If the guard disappears for 5 s while the app is suspended, the agent resumes it anyway so a crash cannot leave the water-block screen frozen. - Log lines go to
%LOCALAPPDATA%\SleepGuard\sleep-guard.log(rotated at 1 MB).
Full write-ups with evidence: docs/troubleshooting.md. The seven that matter if you are adapting this code:
-
WM_INPUTlParam is anHRAWINPUThandle, not aRAWINPUTHEADER*. Dereferencing it kills the process with0xC0000005insideVCRUNTIME140_CLR0400.dllon the first event. CallGetRawInputDatatwice (size, then copy) — astools/RawProbe.csdoes. -
A task-started process cannot suspend the machine. Its token has
SeShutdownPrivilegepresent but0x0;AdjustTokenPrivilegesreports success andSetSuspendStatestill fails witherr=1314. The launch path must betask -> launcher -> WMI Win32_Process.Create(token attribute0x2). -
BOOLEANreturns must be marshalled asbyte. DeclaringSetSuspendStateas returningboolreads the wholeEAX; garbage in the upper bytes makes failures look like successes. -
Build as
/target:exe, not/target:winexe. On the reference machine a winexe build received zero raw-input events for mouse/keyboard while byte-identical/target:execode received everything (same session, desktop, elevation, registrations).AllocConsole()did not help. The guard hides its console viaShowWindow(GetConsoleWindow(), SW_HIDE). -
SC_MONITORPOWERblanks the output, it does not power the panel down. Any input event relights it — including the phantom HID of the very device this tool exists for — which looks like "the screen goes dark and comes back on immediately" and repeats every retry. Only DDC/CI standby (VCP0xD6) holds. Verified withtools/MonitorPower.cs:listshowspower=standbythat survives minutes of phantom reports, whereas afterSC_MONITORPOWERthe VCP state still readspower=on. -
A compiled .NET exe started by Task Scheduler/WMI can get a filtered process view. On the reference machine the guard saw 102 processes while a normal Windows PowerShell in the same session saw 424, and HiMOS was absent from Toolhelp, WTS,
NtQuerySystemInformationandWin32_Processalike;OpenProcess(PROCESS_SUSPEND_RESUME)on a pid handed to it failed witherr=5even withSeDebugPrivilege. Hence the out-of-process pause agent. Raw input is subject to the same filter: the same probe code received raw mouse input when built/target:exeand nothing when built/target:winexe. -
A background helper that "just works" can be dead without saying so. The pause agent survived a 44 h hibernation and then stopped answering; because nobody checked, the panel kept coming back on and the guard still put the machine to sleep at the 10 min mark — the desk saw "it never blanked and then slept out of nowhere". v1.2.1: the agent rewrites
agent-heartbeat.txtevery 2 s, the guard confirms every pause againstpause-state.txt(re-sending up to 6 times,attempt Nin the request so content-based dedupe notices), refuses to sleep while the pause is unconfirmed, and theSleepGuardAgenttask repeats every 10 minutes so a dead agent is restarted by Task Scheduler. -
Every console child of a scheduled task shows up as a Windows Terminal window. On Windows 11 Windows Terminal is the default console host, so a task action of
powershell.exe -File ...opens a window even with-WindowStyle Hidden; with a one-minute repetition it looks like "PowerShell keeps popping up". The fix is a GUI-subsystem helper (tools/HideRun.cs) that callsCreateProcessWwithCREATE_NO_WINDOW+SW_HIDEand gives the child theNULdevice as stdin/stdout/stderr — without those handlespowershell.exeexits with code 1 before running a single line. Two more traps found the hard way: the helper must wait for the child (HideRun.exe -w ...), otherwise Task Scheduler's kill-on-job-close job destroys the script the moment the helper exits; and the helper itself must be installed under%LOCALAPPDATA%\SleepGuard\, because the very same bytes placed inside the project folder start and then exit beforeMain(verified for every name and subfolder).
- Runs in the interactive desktop session only (that is where raw input and
SC_MONITORPOWERlive). - Some fullscreen-exclusive games ignore the monitor-off message. Sleep/hibernate still work.
- Silent video (no audio stream) is not detected by the audio exception; only audio sessions.
- A remote-desktop tool can keep a render session
Activewith nothing audible — add its display name to--phantom-audio(网易UU远程is pre-configured). --pause-appneeds theSleepGuardAgentlogon task (install.ps1 registers it). If the agent is not running nothing is paused: the monitors still blank, they just light up again because of the phantom HID. The task repeats every 10 minutes (IgnoreNew), so a dead agent comes back on its own; the launcher also restarts a missing agent at logon. The guard additionally refuses to sleep/hibernate while its pause request is unconfirmed (max ~1 min), so a dead agent cannot make the machine sleep with the panel still lit.- The hibernate resume timer may fire 30–40 s late on some firmware. Harmless in normal use.
- Tested on Windows 11 (build 26300), x64, .NET Framework 4.8. Windows 10 should work; ARM64 is untested.
- Suspend requires administrator rights (
SeShutdownPrivilege); monitor-off and idle counting work without it.
Get-ScheduledTaskInfo SleepGuard → LastTaskResult 3221225477 (0xC0000005) means an access
violation; Event Viewer → Windows Logs → Application → "Application Error" names the faulting
module. For a dump, enable local dumps once:
$k = 'HKLM:\SOFTWARE\Microsoft\Windows\Windows Error Reporting\LocalDumps\SleepGuard.exe'
New-Item $k -Force | Out-Null
New-ItemProperty $k -Name DumpFolder -Value 'C:\ProgramData\SleepGuard\dumps' -PropertyType ExpandString -Force | Out-Null
New-ItemProperty $k -Name DumpType -Value 1 -PropertyType DWord -Force | Out-NullThe registered task restarts the binary up to 3 times at 1-minute intervals, so a crash does not leave the machine unprotected for long.
Unregister-ScheduledTask -TaskName 'SleepGuard' -Confirm:$false
Get-Process SleepGuard -ErrorAction SilentlyContinue | Stop-Process -Force
Remove-Item "$env:LOCALAPPDATA\SleepGuard" -Recurse -ForceOr run .\uninstall.ps1 -Purge.
Note: after uninstalling you are back to the OS behaviour — and if a phantom HID device is still present, the monitor stays on again. The two permanent alternatives are: disable the offending device in Device Manager, or switch off the "prevent sleep / keep device alive" option in the vendor app.
问题:某些 USB HID 设备(水冷屏控制器、主板/显卡仪表盘、RGB/风扇控制器、宏键盘、虚拟 HID 等)
每秒上报数次输入报告,Windows 认为"用户一直在操作",导致「关闭显示器 / 睡眠 / 休眠」的计时器永远
无法到期 —— 人走了屏幕彻夜亮着、电脑也不睡。powercfg /requests 看不到它,因为这不是电源请求。
解决:SleepGuard 用 Raw Input 自己维护"真实输入时钟",只认真正的键鼠/笔/触控事件,每个空闲
阈值到了就执行:5 分钟关屏 → 10 分钟睡眠 S3 → 30 分钟转休眠 S4;正在播放音视频时只推迟关屏,
不影响睡眠/休眠。若某个程序(例如水冷屏软件 HiMOS)本身在不停伪造输入,关屏时会把它的进程
暂停、你一操作电脑立刻恢复(--pause-app HiMOS,由独立的 SleepGuardAgent 任务执行)。
安装(管理员 PowerShell):
powershell -ExecutionPolicy Bypass -File .\build.ps1
.\install.ps1 -Display 300 -Sleep 600 -Hibernate 1800查日志:Get-Content "$env:LOCALAPPDATA\SleepGuard\sleep-guard.log" -Tail 30
测试:.\tests\run-full-selftest.ps1(安全,不睡眠);加 -Force 才是真睡眠测试。
先诊断到底是谁在捣乱:.\tools\quiet-probe.ps1,把抓到的设备名加进 --phantom。
排查手册见 docs/troubleshooting.md,测试细节见 docs/testing.md。
docs/ci-workflow.yml is a ready-made GitHub Actions workflow: on a Windows runner it builds both
binaries with the in-box compiler, runs a 4-second smoke test (--display 0 --sleep 0, so it never
touches the display) and uploads the exes as an artifact.
GitHub requires the workflow OAuth scope to push files under .github/workflows/, so the file is
shipped as a template. To enable it:
gh auth refresh -s workflow
New-Item -ItemType Directory -Force .github\workflows | Out-Null
Copy-Item docs\ci-workflow.yml .github\workflows\build.yml
git add .github/workflows/build.yml
git commit -m 'ci: add build workflow'
git pushMIT — see LICENSE.