7.4 KiB
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.
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
Download hyprland-hdmi-revive-0.2.1.tar.gz and SHA256SUMS from the
Gitea release.
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_DIRis required. Locks prevent duplicate watchers and overlapping recoveries; there is no shared/tmpfallback. - 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_SECONDSor 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. A hardware input-switch test after installation remains necessary. CI runs the shell checks and behavioral tests on a configured Gitea Actions runner.
See architecture, troubleshooting and security notes. MIT license: LICENSE.