fix(display): docked idle wake + dump without SSH (#127)
All checks were successful
Check / eval (push) Successful in 3m44s

Recover from DPMS/lock blackout without a second machine: undock/enable
always issue dpms on; hypridle on-resume and after_sleep run
nomarchy-display-wake (zero-output undock rescue + auto-dump).
nomarchy-display-dump writes ~/nomarchy-display-dump-*.txt (SUPER+SHIFT+D
or Ctrl+Alt+F3 TTY). HARDWARE-QUEUE #127 no longer requires SSH.

V1: nix flake check --no-build. V3: docked idle on AMD box still open.
This commit is contained in:
2026-07-15 12:37:00 +01:00
parent 52d1581131
commit e9ab0d8d64
7 changed files with 224 additions and 26 deletions

View File

@@ -0,0 +1,103 @@
# Shared display recovery helpers (#127). Used by hyprland.nix (PATH +
# undock dump) and idle.nix (hypridle on-resume / after_sleep absolute paths).
#
# dump — evidence to ~/nomarchy-display-dump-*.txt without SSH
# wake — dpms on + zero-output undock rescue + dump if still dark
# Pass displayTransition so wake pins the same binary hyprland ships.
# Call once with only pkgs to get dump (transition not ready yet), then
# again with displayTransition for wake.
{ pkgs, displayTransition ? null }:
let
dump = pkgs.writeShellScriptBin "nomarchy-display-dump" ''
set -u
reason="''${1:-manual}"
stamp=$(date +%Y%m%d-%H%M%S)
out="''${HOME:-/tmp}/nomarchy-display-dump-''${stamp}.txt"
{
echo "=== nomarchy-display-dump reason=$reason ==="
echo "date: $(date -Is 2>/dev/null || date)"
echo "user: $(id -un 2>/dev/null || echo ?)"
echo "HYPRLAND_INSTANCE_SIGNATURE=''${HYPRLAND_INSTANCE_SIGNATURE:-}"
echo
echo "=== hyprctl monitors (enabled) ==="
hyprctl monitors -j 2>&1 || true
echo
echo "=== hyprctl monitors all ==="
hyprctl monitors all -j 2>&1 || true
echo
echo "=== hyprctl workspaces ==="
hyprctl workspaces -j 2>&1 || true
echo
echo "=== hyprctl devices (keyboards) ==="
hyprctl devices -j 2>/dev/null | ${pkgs.jq}/bin/jq '.keyboards // .' 2>&1 || true
echo
echo "=== systemd-inhibit --list ==="
${pkgs.systemd}/bin/systemd-inhibit --list 2>&1 || true
echo
echo "=== DRM connector status ==="
for s in /sys/class/drm/*/status; do
[ -r "$s" ] || continue
echo "$s: $(cat "$s" 2>/dev/null)"
done
echo
echo "=== journal user display/idle (last 100) ==="
journalctl --user -t nomarchy-display-watch -t nomarchy-display-transition \
-t nomarchy-display-wake -t hypridle -t hyprlock -n 100 --no-pager 2>&1 || true
echo
echo "=== journal -b suspend/sleep/dpms (last 80 matching) ==="
journalctl -b --no-pager 2>/dev/null \
| ${pkgs.gnugrep}/bin/grep -iE 'Suspending|PM: suspend|PM: hibernate|dpms|sleep\.target|systemd-sleep' \
| ${pkgs.coreutils}/bin/tail -n 80 || true
} >"$out" 2>&1
${pkgs.util-linux}/bin/logger -t nomarchy-display-dump -- "reason=$reason wrote $out"
command -v notify-send >/dev/null 2>&1 \
&& notify-send "Display dump" "$out" 2>/dev/null || true
printf '%s\n' "$out"
'';
wake =
if displayTransition == null then null
else pkgs.writeShellScriptBin "nomarchy-display-wake" ''
set -u
LOGGER=${pkgs.util-linux}/bin/logger
log() { "$LOGGER" -t nomarchy-display-wake -- "$*"; }
TRANSITION=${displayTransition}/bin/nomarchy-display-transition
DUMP=${dump}/bin/nomarchy-display-dump
hyprctl dispatch dpms on >/dev/null 2>&1 || true
enabled=$(hyprctl monitors -j 2>/dev/null | ${pkgs.jq}/bin/jq 'length' 2>/dev/null || echo 0)
case "$enabled" in *[!0-9]*|"") enabled=0 ;; esac
if [ "$enabled" -eq 0 ]; then
log "zero-enabled-outputs: attempting internal enable"
internal=$(hyprctl monitors all -j 2>/dev/null \
| ${pkgs.jq}/bin/jq -r '.[] | select(.name | test("^(eDP|LVDS|DSI)")) | .name' 2>/dev/null \
| ${pkgs.coreutils}/bin/head -n1)
if [ -n "$internal" ]; then
if "$TRANSITION" undock "$internal" 2>/dev/null; then
log "rescued via undock internal=$internal"
elif "$TRANSITION" enable "$internal" 2>/dev/null; then
log "rescued via enable internal=$internal"
else
log "rescue failed internal=$internal"
fi
else
log "no internal output in monitors all"
fi
hyprctl dispatch dpms on >/dev/null 2>&1 || true
enabled=$(hyprctl monitors -j 2>/dev/null | ${pkgs.jq}/bin/jq 'length' 2>/dev/null || echo 0)
case "$enabled" in *[!0-9]*|"") enabled=0 ;; esac
if [ "$enabled" -eq 0 ]; then
log "still zero-enabled: dumping"
"$DUMP" auto-zero-after-wake >/dev/null 2>&1 || true
fi
fi
first=$(hyprctl monitors -j 2>/dev/null \
| ${pkgs.jq}/bin/jq -r '.[0].name // empty' 2>/dev/null || true)
[ -n "$first" ] && hyprctl dispatch focusmonitor "$first" >/dev/null 2>&1 || true
log "done enabled=$enabled first=''${first:-none}"
'';
in
{
displayDumpTool = dump;
displayWakeTool = wake;
}

View File

@@ -124,6 +124,10 @@ let
profileWorkspaceRules =
lib.mapAttrsToList monitorLib.workspaceRule activeProfile.workspaces;
# #127 dump tool first (no transition dep); wake is wired after transition.
displayDumpTool =
(import ./display-tools.nix { inherit pkgs; }).displayDumpTool;
# One transition primitive for the interactive Dock mode, abrupt undock,
# and named profiles that disable the internal panel. Dock is one Hyprland
# batch: enable the target, move every internal workspace, focus it, then
@@ -233,8 +237,13 @@ let
if ! output_enabled "$internal" 5; then
log "transition=undock internal=$internal keyword=inert escalate=reload"
hyprctl reload >/dev/null 2>&1
output_enabled "$internal" 25 \
|| { log "transition=undock internal=$internal result=enable-failed"; exit 1; }
if ! output_enabled "$internal" 25; then
log "transition=undock internal=$internal result=enable-failed"
# #127: leave evidence on disk without SSH (TTY or next login).
${displayDumpTool}/bin/nomarchy-display-dump undock-enable-failed \
>/dev/null 2>&1 || true
exit 1
fi
# The config's monitor rules own the panel now. Re-assert the
# runtime rule which flushes normally, an output being enabled
# again so a config that parks the panel off cannot undo the
@@ -246,6 +255,9 @@ let
moves=$(all_workspaces | batch_moves "$internal")
hyprctl --batch "$moves dispatch focusmonitor $internal" >/dev/null 2>&1 \
|| { log "transition=undock internal=$internal result=move-failed"; exit 1; }
# #127: undock after idle DPMS left the seat black even when the
# panel was re-enabled always light the chain after enable.
hyprctl dispatch dpms on >/dev/null 2>&1 || true
log "transition=undock internal=$internal result=ok" ;;
enable)
internal="''${2:-}"
@@ -255,13 +267,24 @@ let
# `monitors` the proof, or the menu's "back on" notification cheers
# for a keyword hyprctl merely said "ok" to.
hyprctl keyword monitor "$internal,preferred,auto,1" >/dev/null 2>&1
output_enabled "$internal" 10 ;;
if output_enabled "$internal" 10; then
hyprctl dispatch dpms on >/dev/null 2>&1 || true
true
else
false
fi ;;
*)
echo "usage: nomarchy-display-transition [dock <internal> <external> [external-rule]|undock <internal>|enable <internal>]" >&2
exit 64 ;;
esac
'';
displayWakeTool =
(import ./display-tools.nix {
inherit pkgs;
displayTransition = displayTransitionTool;
}).displayWakeTool;
# Profile applier — on PATH only when profiles are declared (the menu
# row self-gates on it). apply: the profile's rules live via hyprctl +
# the in-flake state write; base: clear the state, then hyprctl reload
@@ -1127,6 +1150,7 @@ in
# debugging. The display-profile switcher itself remains gated so the
# menu does not advertise an empty Profiles submenu.
++ lib.optionals config.nomarchy.hyprland.enable
([ keyboardTool keyboardWatch displayTransitionTool displayProfileWatch ]
([ keyboardTool keyboardWatch displayTransitionTool displayProfileWatch
displayDumpTool displayWakeTool ]
++ lib.optional (profiles != { }) displayProfileTool);
}

View File

@@ -19,6 +19,52 @@ let
done
exit 1
'';
# Same transition binary hyprland.nix puts on PATH — re-derived here so
# hypridle can pin absolute store paths (thin PATH in the user unit).
# Must stay in lockstep with modules/home/hyprland.nix's transition.
# We only need undock/enable + dpms; call via PATH-installed name is
# fragile, so wake embeds the transition from a minimal import chain:
# dump+wake live in display-tools.nix; transition is the public name
# on PATH after HM activation — wake script uses absolute path from
# a local reimplementation of just the enable/dpms rescue when the
# full transition package isn't injectable without circular imports.
#
# Practical approach: invoke `nomarchy-display-wake` from PATH after
# activation; for the unit Exec lines use a wrapper that prefers the
# store path of a wake script built with transition = null fallback
# (hyprctl keyword + reload + dpms) matching undock's public contract.
displayWake =
let
# Minimal undock-equivalent for idle's store path (no circular dep on
# hyprland.nix). Prefer the real transition when on PATH at runtime.
miniTransition = pkgs.writeShellScriptBin "nomarchy-display-transition" ''
set -u
case "''${1:-}" in
undock|enable)
internal="''${2:-}"
[ -n "$internal" ] || exit 64
hyprctl keyword monitor "$internal,preferred,auto,1" >/dev/null 2>&1 || true
if ! hyprctl monitors -j 2>/dev/null \
| ${pkgs.jq}/bin/jq -e --arg m "$internal" 'any(.[]; .name == $m)' \
>/dev/null 2>&1; then
hyprctl reload >/dev/null 2>&1 || true
sleep 0.5
fi
hyprctl keyword monitor "$internal,preferred,auto,1" >/dev/null 2>&1 || true
hyprctl dispatch dpms on >/dev/null 2>&1 || true
hyprctl monitors -j 2>/dev/null \
| ${pkgs.jq}/bin/jq -e --arg m "$internal" 'any(.[]; .name == $m)' \
>/dev/null 2>&1
;;
*) exit 64 ;;
esac
'';
in
(import ./display-tools.nix {
inherit pkgs;
displayTransition = miniTransition;
}).displayWakeTool;
in
{
config = lib.mkIf cfg.idle.enable {
@@ -111,7 +157,10 @@ in
# can't be safely dropped after the fact — killing the locker trips
# its "go to a tty" crash failsafe). See nomarchy-lock-before-sleep
# in modules/nixos/default.nix.
after_sleep_cmd = "hyprctl dispatch dpms on";
# #127: not only dpms on — if dock mode left zero enabled
# outputs (eDP disabled + external gone/black), re-enable the
# internal. Absolute store path: hypridle's PATH is thin.
after_sleep_cmd = "${lib.getExe displayWake}";
};
listener = [
# Lock and screen-off are the same on either power source —
@@ -120,7 +169,8 @@ in
{
timeout = 600;
on-timeout = "hyprctl dispatch dpms off";
on-resume = "hyprctl dispatch dpms on";
# Same rescue path as after_sleep (#127).
on-resume = "${lib.getExe displayWake}";
}
# Suspend only on battery, and sooner than the old fixed 30 min
# (it now only fires unplugged). Plugged in, the machine stays

View File

@@ -67,6 +67,10 @@
# place that decides how a lock actually looks.
{ mods = "$mod CTRL"; key = "L"; action = "exec, loginctl lock-session"; desc = "Lock screen"; group = "Menu"; }
{ mods = "$mod SHIFT"; key = "C"; action = "exec, hyprpicker -a"; desc = "Color picker ( clipboard)"; group = "Menu"; }
# #127: dump display state to ~/nomarchy-display-dump-*.txt without SSH.
# If the seat is locked, hyprlock may swallow this — use Ctrl+Alt+F3 and
# run `nomarchy-display-dump` / `nomarchy-display-wake` on the TTY instead.
{ mods = "$mod SHIFT"; key = "D"; action = "exec, nomarchy-display-dump keybind"; desc = "Dump display diagnostics (~/)"; group = "Menu"; }
# Focus — SUPER + arrow keys.
{ mods = "$mod"; key = "left"; action = "movefocus, l"; desc = "Focus left"; group = "Window"; }