Hyprland HDMI Revive

Recover a black HDMI display after switching a shared physical monitor away from an Arch Linux / Hyprland workstation and back. A DRM hotplug event triggers a short switch away from the active graphical VT and back. There is no periodic DPMS toggle or polling timer.

Version 0.2.1. The initial implementation and the review fixes are separate commits. See the code review and changelog.

Physical HDMI input switching and recovery were confirmed working on 2026-09-23 with Hyprland 0.56.2 (Lua configuration) and an iiyama PL3288UH on HDMI-A-2. See validation results and remaining checks.

Requirements

A systemd-based Linux desktop with Hyprland, a local seat0 Wayland session, Bash, coreutils (timeout, stdbuf, stat), jq, flock, sudo and /usr/bin/chvt. On Arch Linux:

sudo pacman -S --needed hyprland systemd bash coreutils jq util-linux kbd sudo

The intended setup is one local Hyprland session per user. The monitor must emit DRM HOTPLUG=1 events when its physical input changes. No event means no automatic recovery. Hyprland must still expose the configured monitor over its IPC socket.

Install from the package

Use hyprland-hdmi-revive-0.2.1.tar.gz and SHA256SUMS from a published Gitea release or build the bundle from source. A Git push alone does not publish installer bundles. Run as your desktop user, without sudo:

sha256sum --ignore-missing -c SHA256SUMS
tar -xzf hyprland-hdmi-revive-0.2.1.tar.gz
cd hyprland-hdmi-revive-0.2.1
./scripts/install.sh

This is a portable user installer bundle, not a pacman system package. It installs:

  • executables under ~/.local/bin/;
  • shared code under ~/.local/lib/hyprland-hdmi-revive/;
  • the unit under ${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user/;
  • a new config under ${XDG_CONFIG_HOME:-$HOME/.config}/revive-monitor/config.env.

Existing configuration is preserved. A running instance is stopped before its files are replaced. Installation alone does not enable or start the service.

Alternatively, clone the source and use the same installer:

git clone https://gitea.jr-it-services.de/johannes/hyprland-hdmi-revive.git
cd hyprland-hdmi-revive
./scripts/install.sh

Configure

Find the monitor name from a terminal inside Hyprland:

hyprctl monitors
nano "${XDG_CONFIG_HOME:-$HOME/.config}/revive-monitor/config.env"

Example:

MONITOR=HDMI-A-2
DEBOUNCE_SECONDS=30
HDMI_SETTLE_SECONDS=5
VT_SWITCH_DELAY_SECONDS=1
VT_RETURN_SETTLE_SECONDS=2

Timing values are whole seconds: 0–300 for waits and 0–3600 for debounce. Config is trusted Bash sourced as your user; do not copy untrusted shell code into it.

Allow the VT switch

Edit with sudo visudo -f /etc/sudoers.d/revive-monitor-chvt. Prefer explicit VT arguments when the machine always uses VT1 and VT2:

YOUR_USER ALL=(root) NOPASSWD: /usr/bin/chvt 1, /usr/bin/chvt 2

Replace YOUR_USER. For changing graphical VTs, a broader chvt-only rule is in sudoers/revive-monitor-chvt.example. Never grant sudo access to these scripts. The helper checks permission for both directions before leaving the desktop and uses sudo -n with command timeouts. A return failure gets one emergency retry.

Connect the Hyprland session

The user service manager does not automatically inherit a terminal's Hyprland environment. For a Lua configuration, add this hook to your loaded Lua config:

hl.on("hyprland.start", function()
  hl.exec_cmd([[sh -c '
    export XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-$HOME/.config}"
    systemctl --user import-environment HYPRLAND_INSTANCE_SIGNATURE XDG_CONFIG_HOME &&
    systemctl --user restart revive-monitor-drm-watch.service
  ']])
end)

For a legacy hyprlang configuration, use this line instead (commonly in ~/.config/hypr/hyprland.conf):

exec-once = sh -c 'export XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-$HOME/.config}"; systemctl --user import-environment HYPRLAND_INSTANCE_SIGNATURE XDG_CONFIG_HOME && systemctl --user restart revive-monitor-drm-watch.service'

It imports the current IPC instance at each Hyprland login, including after logging out and back in. A custom XDG_CONFIG_HOME must also be consistent with the user manager's unit search path. See the official hyprctl documentation and systemd environment documentation.

For the current login, after configuring the output and sudoers:

./scripts/install.sh --start
systemctl --user status revive-monitor-drm-watch.service
journalctl --user -u revive-monitor-drm-watch.service -b

--start imports the current instance, reloads systemd, enables and restarts the unit. Use it for updates as well. The service remains attached to default.target because not every Hyprland setup activates graphical-session.target; session and IPC guards prevent pre-login VT switching.

Behavior and limits

  • Events are accepted only for this UID's active local Wayland session on seat0. A reported non-Hyprland desktop is excluded.
  • The helper independently verifies the requested VT and rechecks the session after settling and permission checks.
  • DPMS/focus commands follow the active config provider reported by hyprctl -j status: Lua or legacy hyprlang. Older versions without this query use legacy commands. A refresh error is logged before aborting recovery.
  • A private XDG_RUNTIME_DIR is required. Locks prevent duplicate watchers and overlapping recoveries; there is no shared /tmp fallback.
  • Missing IPC, invalid monitor names, command failures and lost event streams produce error logs. A terminated event stream causes systemd to restart the watcher.
  • Signals trigger a best-effort return to the graphical VT. SIGKILL, power loss and persistent sudo/kernel failures cannot be recovered by a shell trap.
  • DRM events are not reliably attributed to a particular monitor. Another output's hotplug can trigger this workaround too.
  • Debounce is a cooldown measured after a recovery attempt. It suppresses queued events, including events caused by the VT switch. It can also suppress a real rapid input return; if that happens, try a shorter DEBOUNCE_SECONDS or run the helper manually with the correct graphical VT.

Uninstall

From the unpacked bundle or checkout:

./scripts/uninstall.sh

The user manager must be reachable. Config and sudoers remain intact. Remove the Hyprland startup line and, if no longer needed, the dedicated sudoers entry with visudo. Failure state for unrelated services is preserved.

Build and validate

Python 3 is needed only for tests; ShellCheck is a development dependency.

for script in scripts/*.sh lib/*.sh; do bash -n "$script" || exit; done
shellcheck -x scripts/*.sh lib/*.sh
python3 -B -m unittest discover -s tests -v
./scripts/build-release.sh

The release builder requires clean tracked files and packages committed HEAD. It creates a .tar.gz installer bundle, source ZIP, Base64 ZIP and SHA256SUMS in dist/. Untracked files and local config are excluded. The Base64 ZIP can also be decoded on Windows with certutil -decode <file.zip.b64> <file.zip>.

Tests mock loginctl, Hyprland IPC, udev, sudo and systemd. They never switch the real display. Version 0.2.1 passed 30 behavioral tests and a physical input-switch test on the documented setup. Test input switching after installation on other hardware as well. CI runs the shell checks and behavioral tests on a configured Gitea Actions runner.

See architecture, troubleshooting and security notes. MIT license: LICENSE.

S
Description
HDMI Switch for arch linux
Readme MIT
91 KiB
0 Stars 1 Watchers 0 Forks
2026-09-23 09:18:48 +02:00
Languages
Python 50.2%
Shell 49.8%