Skip to content

Staged idle on Omarchy: both monitors physically OFF until real activity + USB keyboard power-cut (with 2 upstream findings) #124

Description

@gcap0n1

Staged idle on Omarchy: both monitors physically OFF until real activity + USB keyboard power-cut (+ two upstream bugs found on the way)

Environment

Component Version
Omarchy dev (93010924)
Kernel 7.1.9-arch1-2
Hyprland 0.56.2-1
Quickshell 0.3.1-1
hypridle 0.1.8-1 (now disabled)
hyprlock not installed

TL;DR

Goal: laptop panel + external ultrawide must switch completely off together after idle, stay off despite the screensaver/wake machinery, keyboard lose USB power, then suspend later — surviving reboots and OS updates.

How it works now: a small shell plugin replacing omarchy.idle stages four timeouts (dim → screensaver → physical output disable + keyboard power-cut → suspend). The displays are physically disabled, not just DPMS-blanked, so nothing in the desktop can accidentally light them up again; they come back only through one explicit, ordered restore chain triggered by real activity. Verified end-to-end live, including a genuine suspend/resume round-trip.

On the way, two reproducible upstream problems surfaced (each with diagnosis + workaround below):

  1. A jittery wireless-mouse dongle streaming ~1.3 KB/s of phantom REL_X/REL_Y events defeats idle detection for the entire desktop — quickshell and hypridle alike.
  2. Quickshell's IdleMonitor object goes stale across suspend→resume and silently stops reporting idleness until the shell restarts.

THE CORE SOLUTION: keeping both monitors off

Why every "normal" approach failed

  • hypridle + DPMS: worked once, then broke after reboot/update. Root causes stacked: the manually-launched daemon died with its terminal, and hypridle is scheduled for retirement in upcoming releases anyway.
  • DPMS-off alone: the lock/screensaver flow itself wakes displays, and any activity runs the wake path (omarchy-system-wake) which re-enables DPMS + brightness. Net effect observed: dim → DPMS-off → screensaver lights everything back up → off again → external panel comes back with the screensaver. Unusable.
  • Physically disabling outputs (hl.monitor({output = ..., disabled = true})) is the only thing that actually sticks — provided you control exactly when they come back. Key property discovered by testing: input events do NOT revive a disabled output. Nothing spontaneous lights the panels; only code that explicitly re-adds them can. That flips the problem from "fight the compositor" to "own one boolean".

Mechanism 1 — physical disable

hyprctl eval 'hl.monitor({ output = "eDP-1",   disabled = true })'
hyprctl eval 'hl.monitor({ output = "HDMI-A-1", disabled = true })'

Note the Lua-expression form: on current builds plain dispatcher args are parsed as Lua source, so keyword monitor X,disable errors out.

Mechanism 2 — staying off: three guards

  1. Single authority: one displaysOffActive flag owns the state. Restore logic is a no-op unless that flag says we powered down.
  2. Anti-fight-loop guard: disabling an output can make the compositor close/migrate the screensaver window without user activity. The plugin distinguishes "screensaver closed because WE turned the displays off" (ignore, log screensaver-close-ignored) from "user dismissed it" (cancel pending stages). Without this, display-off and cancel loop against each other within seconds.
  3. No accidental wake paths: while displaysOffActive, compositor-reported activity is ignored if it coincides with the screensaver teardown; only the real activity signal fires the restore.

Mechanism 3 — the restore chain, and why the order matters

Triggered by genuine activity (or resume-from-suspend):

omarchy-system-wake                                            # stock DPMS/brightness/clamshell recovery first
hyprctl eval 'hl.monitor({ output = "eDP-1",    disabled = false })'   # explicit re-add...
hyprctl eval 'hl.monitor({ output = "HDMI-A-1", disabled = false })'   # ...preserves configured mode/scale
brightnessctl -r                                               # backlight back
sudo -n /usr/local/sbin/omarchy-kbd-power on                   # keyboard power restored

Critical detail: DPMS-enable does not revive a physically disabled output, so the explicit re-add is mandatory — and because it goes through the Lua layer, the per-output scale from monitors.lua (laptop at 1.09, ultrawide at 1.0) is preserved instead of reset. Verified byte-identical layout before/after.

Mechanism 4 — cutting USB power to the keyboard

/sys/bus/usb/devices/*/power/control is root-writable only, and months of ... 2>/dev/null && echo auto > ... had been failing with silent EPERM. Three traps:

  • Node identification: 3-3 was a Genesys hub; the real devices live on child ports. Writing to the hub does nothing useful.
  • Ports move: after re-plugging hardware our receiver silently migrated 3-3.11-3; hardcoded ports rot invisibly (the failure was swallowed by || true).
  • Whitelisting: don't hand the agent blanket sudo. One strict helper + one narrow rule:

The helper resolves targets dynamically by VID:PID at call time, so re-plugs don't break it:

#!/bin/bash
# /usr/local/sbin/omarchy-kbd-power — cut/restore USB power by device identity
set -u
action="${1:-}"
case "$action" in off|on) ;; *) echo "usage: omarchy-kbd-power off|on" >&2; exit 2 ;; esac
targets=("145f:0322" "3554:fc06")   # keyboard + wireless receiver
rc=0
for vidpid in "${targets[@]}"; do
  node=""
  for d in /sys/bus/usb/devices/*/idVendor; do
    dir=${d%/*}
    [[ -f $dir/idProduct ]] || continue
    id="$(printf '%s:%s' "$(cat "$dir/idVendor")" "$(cat "$dir/idProduct")")"
    if [[ ${id,,} == "$vidpid" ]]; then node=${dir##*/}; break; fi
  done
  if [[ -z $node ]]; then echo "device $vidpid not found on any USB bus" >&2; rc=1; continue; fi
  base="/sys/bus/usb/devices/$node"
  case "$action" in
    off) echo auto > "$base/power/control" || rc=1
         echo 0   > "$base/power/autosuspend_delay_ms" || rc=1 ;;
    on)  echo on   > "$base/power/control" || rc=1 ;;
  esac
done
exit $rc
# /etc/sudoers.d/<user>-omarchy-kbd-power  (0440, visudo-validated)
<user> ALL=(root) NOPASSWD: /usr/local/sbin/omarchy-kbd-power

Semantics: off = control=auto + autosuspend_delay_ms=0 (immediate autosuspend), on = control=on. Failure policy: called with || true, so a missing sudo rule or absent device degrades only the keyboard stage — never display-off.

Honest runtime-PM limitation: control=auto permits autosuspend but cannot force a device whose firmware streams interrupt-IN reports continuously. Our Trust keyboard's SINO WEALTH controller transmits non-stop, so runtime_status stays active and its LEDs stay lit through the idle cycle (the quieter receiver suspends fine). A guaranteed physical cut needs hub per-port power switching (uhubctl-style PPPS) or vendor-specific hidraw commands — worst case, the suspend stage powers everything down regardless.

Staging & configuration

Plugin <user>.idle (clone of the built-in service pattern, community parado.idle as starting point) supersedes the builtin via disabledPlugins: ["omarchy.idle"]. All timings live in ~/.config/omarchy/shell.json; a missing key disables that stage (safe defaults):

Stage Key Production Action
Dim idle.brightness 300 s brightnessctl -s set 10
Screensaver idle.screensaver 330 s omarchy-launch-screensaver
Display off idle.displayOff 360 s both outputs disabled + kbd-power off
Suspend idle.suspend 1800 s systemctl suspend

Because the plugin is user-owned config, OS updates don't touch it — no chattr +i, no pacman hooks fighting the package manager.


Upstream finding A: phantom HID traffic defeats idle detection desktop-wide

A budget 2.4 GHz receiver (3554:fc06 2.4G Receiver) streamed continuous phantom pointer motion while untouched:

45 s true-quiet, /dev/input/event6 ("2.4G Receiver Mouse"):
59,184 bytes — REL_X ×730, REL_Y ×803, SYN ×921 (~1.3 KB/s)

Every REL_* counts as input → ext-idle-notify-v1 never fires → quickshell idle, hypridle, everything stays awake forever. Invisible in logs. USB autosuspend does not mitigate (device never idles long enough to suspend).

Diagnosis one-shot (60 s hands-off):

python3 - <<'EOF'
import os, select, time, glob
devs = {p: os.open(p, os.O_RDONLY | os.O_NONBLOCK) for p in glob.glob('/dev/input/event*')}
end = time.time() + 60; counts = {}
while time.time() < end:
    r, _, _ = select.select(list(devs.values()), [], [], 0.5)
    for fd in r:
        n = len(os.read(fd, 4096))
        name = [k for k, v in devs.items() if v == fd][0]
        counts[name] = counts.get(name, 0) + n
for p, b in sorted(counts.items(), key=lambda x: -x[1]): print(f"{b:>8} B  {p}")
EOF
grep NAME /sys/class/input/input*/device/name ; lsusb -t

Fix was hardware (battery/re-pair/moving dongle away from BT+webcam RF): zero bytes afterwards, idle fires immediately. Suggestion: troubleshooting-doc entry for "idle never fires" — this meter turns hours into minutes.

Upstream finding B: Quickshell IdleMonitor stale after suspend→resume

After one idle cycle ended in suspend, the monitor object never reported idleness again post-resume (independent Wayland probe fired IDLED while quickshell stayed silent; only a shell restart fixed detection).

Workaround in the plugin — wrap the monitor in a Loader, recreate it when provably stale:

Loader {
  id: idleLoader
  sourceComponent: Component {
    IdleMonitor { enabled: root.idleEnabled && root.firstIdleTimeoutSeconds > 0
                  timeout: Math.max(1, root.firstIdleTimeoutSeconds)
                  respectInhibitors: root.respectInhibitors }
  }
}
Timer { interval: 30000; repeat: true; running: root.idleEnabled
        onTriggered: cursorProbe.running = true }
Process { id: cursorProbe
          command: ["hyprctl", "cursorpos"]
          stdout: StdioCollector { onStreamFinished: root.noteCursorPos(this.text.trim()) } }

Heal condition (fail-safe): cursor static > max(120s, 3×timeout) while monitor claims active, no cycle running, valid hyprctl data; rate-limited to one recreate per quiet window. Suggestion: re-arm the quickshell monitor on compositor resume, or adopt this pattern in omarchy.idle.


Live verification (journal timestamps)

t+0     idle-cycle-start (test timings 15/20/25/150)
t+15    brightness-dim exitCode=0
t+20    screensaver launched exitCode=0
t+25    display-off exitCode=0 — eDP-1+HDMI-A-1 disabled, kbd nodes control=auto
        close-guard fired twice: "screensaver-close-ignored" ✓ (no fight loop)
t+152   systemctl suspend exitCode=0 → kernel PM suspend entry (deep)
+65min  user input → PM suspend exit → idle-cycle-cancel: activity
        restore exitCode=0 — monitors identical to baseline (scale preserved),
        brightness 100%, kbd nodes control=on, zero plugin errors

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions