197 lines
7.9 KiB
Markdown
197 lines
7.9 KiB
Markdown
# 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](docs/code-review.md) and [changelog](CHANGELOG.md).
|
||
|
||
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](docs/hardware-validation.md).
|
||
|
||
## 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:
|
||
|
||
```bash
|
||
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](https://gitea.jr-it-services.de/johannes/hyprland-hdmi-revive/releases)
|
||
or [build the bundle from source](#build-and-validate). A Git push alone does not
|
||
publish installer bundles.
|
||
Run as your desktop user, **without sudo**:
|
||
|
||
```bash
|
||
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:
|
||
|
||
```bash
|
||
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:
|
||
|
||
```bash
|
||
hyprctl monitors
|
||
nano "${XDG_CONFIG_HOME:-$HOME/.config}/revive-monitor/config.env"
|
||
```
|
||
|
||
Example:
|
||
|
||
```bash
|
||
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:
|
||
|
||
```sudoers
|
||
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:
|
||
|
||
```lua
|
||
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`):
|
||
|
||
```ini
|
||
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](https://wiki.hypr.land/configuring/core/advanced-configuration/using-hyprctl/)
|
||
and [systemd environment documentation](https://www.freedesktop.org/software/systemd/man/latest/environment.d.html).
|
||
|
||
For the current login, after configuring the output and sudoers:
|
||
|
||
```bash
|
||
./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:
|
||
|
||
```bash
|
||
./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.
|
||
|
||
```bash
|
||
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](docs/hardware-validation.md). 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](docs/architecture.md), [troubleshooting](docs/troubleshooting.md)
|
||
and [security notes](SECURITY.md). MIT license: [LICENSE](LICENSE).
|