Compare commits

...
2 Commits
9 changed files with 260 additions and 12 deletions

No files matched your search

+18
View File
@@ -2,6 +2,24 @@
All notable changes to this project will be documented here. All notable changes to this project will be documented here.
## 0.2.1 - 2026-09-23
### Fixed
- Detect the active Hyprland config provider and use Lua DPMS/focus dispatchers
for Lua sessions. Legacy hyprlang sessions retain their original commands.
- Log the failing refresh operation, exit status and Hyprland error response;
reject error responses even when hyprctl exits successfully.
- Add regression coverage for Lua, older Hyprland without `status`, and refresh
failures before and after the VT switch. Document the Lua startup hook.
### Validated
- 30 behavioral tests, Bash syntax checks and ShellCheck 0.11.0 passed.
- Full manual recovery completed on the desktop; the operator then confirmed
physical HDMI input switching and recovery on Hyprland 0.56.2 with Lua and
an iiyama PL3288UH on `HDMI-A-2`. See [validation details](docs/hardware-validation.md).
## 0.2.0 - 2026-09-23 ## 0.2.0 - 2026-09-23
### Fixed ### Fixed
+30 -7
View File
@@ -5,9 +5,13 @@ 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 short switch away from the active graphical VT and back. There is no periodic
DPMS toggle or polling timer. DPMS toggle or polling timer.
**Version 0.2.0.** The initial implementation and the review fixes are separate **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). 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 ## Requirements
A systemd-based Linux desktop with Hyprland, a local `seat0` Wayland session, A systemd-based Linux desktop with Hyprland, a local `seat0` Wayland session,
@@ -24,14 +28,16 @@ recovery. Hyprland must still expose the configured monitor over its IPC socket.
## Install from the package ## Install from the package
Download `hyprland-hdmi-revive-0.2.0.tar.gz` and `SHA256SUMS` from the 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). [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**: Run as your desktop user, **without sudo**:
```bash ```bash
sha256sum --ignore-missing -c SHA256SUMS sha256sum --ignore-missing -c SHA256SUMS
tar -xzf hyprland-hdmi-revive-0.2.0.tar.gz tar -xzf hyprland-hdmi-revive-0.2.1.tar.gz
cd hyprland-hdmi-revive-0.2.0 cd hyprland-hdmi-revive-0.2.1
./scripts/install.sh ./scripts/install.sh
``` ```
@@ -92,7 +98,19 @@ uses `sudo -n` with command timeouts. A return failure gets one emergency retry.
### Connect the Hyprland session ### Connect the Hyprland session
The user service manager does not automatically inherit a terminal's Hyprland The user service manager does not automatically inherit a terminal's Hyprland
environment. Add this line to your Hyprland configuration (commonly 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`): `~/.config/hypr/hyprland.conf`):
```ini ```ini
@@ -124,6 +142,9 @@ and IPC guards prevent pre-login VT switching.
A reported non-Hyprland desktop is excluded. A reported non-Hyprland desktop is excluded.
- The helper independently verifies the requested VT and rechecks the session - The helper independently verifies the requested VT and rechecks the session
after settling and permission checks. 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 - A private `XDG_RUNTIME_DIR` is required. Locks prevent duplicate watchers and
overlapping recoveries; there is no shared `/tmp` fallback. overlapping recoveries; there is no shared `/tmp` fallback.
- Missing IPC, invalid monitor names, command failures and lost event streams - Missing IPC, invalid monitor names, command failures and lost event streams
@@ -166,7 +187,9 @@ It creates a `.tar.gz` installer bundle, source ZIP, Base64 ZIP and `SHA256SUMS`
be decoded on Windows with `certutil -decode <file.zip.b64> <file.zip>`. 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 Tests mock loginctl, Hyprland IPC, udev, sudo and systemd. They never switch the
real display. A hardware input-switch test after installation remains necessary. 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. CI runs the shell checks and behavioral tests on a configured Gitea Actions runner.
See [architecture](docs/architecture.md), [troubleshooting](docs/troubleshooting.md) See [architecture](docs/architecture.md), [troubleshooting](docs/troubleshooting.md)
+1 -1
View File
@@ -1 +1 @@
0.2.0 0.2.1
+4 -1
View File
@@ -11,7 +11,10 @@ active graphical VT. It is intended for one local Hyprland session on `seat0`.
timestamps are not read from shared files or affected by wall-clock changes. timestamps are not read from shared files or affected by wall-clock changes.
4. The synchronous recovery helper locks, independently identifies the active 4. The synchronous recovery helper locks, independently identifies the active
session, waits for HDMI settling, checks the Hyprland IPC output and refreshes session, waits for HDMI settling, checks the Hyprland IPC output and refreshes
DPMS/focus. The instance signature comes from the Hyprland startup hook. DPMS/focus. `hyprctl -j status` selects Lua or legacy hyprlang dispatch syntax;
versions without the query use legacy commands. Dispatches must return `ok`
with exit status zero, otherwise their error is logged and recovery aborts.
The instance signature comes from the Hyprland startup hook.
5. Before switching, the helper rechecks session identity and VT and checks sudo 5. Before switching, the helper rechecks session identity and VT and checks sudo
permission for both directions. It rechecks session identity after those calls. permission for both directions. It rechecks session identity after those calls.
6. The helper sets an EXIT/signal trap, switches to VT2 (when graphical VT=1) or 6. The helper sets an EXIT/signal trap, switches to VT2 (when graphical VT=1) or
+21 -1
View File
@@ -41,4 +41,24 @@ kann einen echten schnellen Rückwechsel unterdrücken. Das bleibt eine bewusste
Einschränkung dieses hardwareabhängigen Workarounds und ist in README beschrieben. Einschränkung dieses hardwareabhängigen Workarounds und ist in README beschrieben.
Ein realer Test von HDMI-Eingangswechsel, Ab-/Anmeldung und Boot-Verhalten wurde Ein realer Test von HDMI-Eingangswechsel, Ab-/Anmeldung und Boot-Verhalten wurde
in diesem Review nicht ausgeführt. Er bleibt nach Installation erforderlich. im ursprünglichen Review für 0.2.0 nicht ausgeführt. Der anschließende Praxistest
für 0.2.1 ist im folgenden Nachtrag dokumentiert.
## Nachtrag: Lua-Korrektur und Praxistest für 0.2.1
Die Prüfung am Desktop zeigte, dass Hyprland 0.56.2 mit Lua-Konfiguration die
alten `dispatch dpms on`-/`dispatch focusmonitor`-Befehle ablehnt. Version 0.2.0
brach dadurch vor dem VT-Wechsel ab. Die bisherigen Mocks hatten beliebige
Dispatch-Aufrufe akzeptiert und diesen Fehler nicht erkannt.
Version 0.2.1 erkennt den aktiven Konfigurationsanbieter über `hyprctl -j status`
und verwendet die passenden Lua- oder hyprlang-Befehle. Die Mocks prüfen nun die
Befehlssyntax; fehlgeschlagene Refresh-Aufrufe protokollieren Operation,
Exitstatus und Fehlerantwort. Alle 30 Verhaltenstests sowie Bash-Syntaxprüfung
und ShellCheck 0.11.0 waren erfolgreich.
Der vollständige manuelle Recovery-Lauf am Desktop kehrte auf die grafische VT1
zurück und meldete `Recovery completed for HDMI-A-2`. Anschließend bestätigte
der Betreiber am 23.09.2026 den erfolgreichen physischen HDMI-Eingangswechsel.
Testumgebung und noch nicht separat bestätigte Prüfungen sind unter
[Hardware validation](hardware-validation.md) festgehalten.
+51
View File
@@ -0,0 +1,51 @@
# Hardware validation
## Confirmed on 2026-09-23
Version 0.2.1 was installed and the systemd user watcher restarted on the
following setup:
| Component | Tested configuration |
| --- | --- |
| Desktop | Arch Linux, Hyprland 0.56.2 |
| Configuration provider | Lua (`hyprctl -j status`) |
| Session | Local Wayland session on seat0, graphical VT1 |
| Display | iiyama PL3288UH, 3840 × 2160 at 60 Hz |
| Output | `HDMI-A-2` |
| Timing | 5 s HDMI settle, 1 s temporary VT, 2 s return settle, 30 s cooldown |
The original failure was reproduced: legacy DPMS/focus commands returned Lua
syntax errors and stopped recovery before the VT switch. Both corrected Lua
commands returned `ok` on the running compositor.
After installation, a complete manual recovery returned to graphical VT1 and
reported:
```text
revive-monitor-fix: Recovery started for HDMI-A-2 on VT 1
revive-monitor-fix: Using lua dispatch syntax
revive-monitor-fix: VT refresh: 2 -> 1
revive-monitor-fix: Recovery completed for HDMI-A-2
```
The watcher remained active. The operator then tested physical HDMI input
switching and confirmed that recovery worked successfully.
## Automated validation
- All 30 behavioral tests passed, including Lua and legacy dispatch syntax,
refresh failures, session guards, VT return handling and installer behavior.
- Bash syntax checks and ShellCheck 0.11.0 passed.
- Installer archive and ZIP contents matched; ZIP integrity, Base64 round-trip
and release checksums were verified.
## Scope and remaining checks
This confirms the normal HDMI input-switch path on the setup above. It does
not establish compatibility with every monitor or GPU.
The operator had reported that the earlier unwanted tty switching was gone.
A dedicated cold-boot/pre-login check and logout/login cycle after the 0.2.1
update have not been separately confirmed. Rapid away/back switching inside
the cooldown also remains a separate test; such events can be suppressed by
design. See [the hardware acceptance procedure](troubleshooting.md#hardware-acceptance).
+25
View File
@@ -36,6 +36,22 @@ Expected: your numeric UID, `Type=wayland`, `Active=yes`, `Seat=seat0`, a VT fro
1 to 63, and either a Hyprland desktop or an empty Desktop property. The helper 1 to 63, and either a Hyprland desktop or an empty Desktop property. The helper
also checks these properties when invoked manually. also checks these properties when invoked manually.
## Hyprland refresh failed / Lua syntax error
Version 0.2.0 sent legacy dispatch commands even to Lua-configured sessions.
On Hyprland 0.56.2 this produced `')' expected near 'on'` and aborted recovery
before the VT switch. Update to 0.2.1 with `./scripts/install.sh --start`.
The helper now reads `hyprctl -j status` to choose the active config provider.
For Lua it sends `hl.dsp.dpms({ action = "on", monitor = "HDMI-A-2" })` and
`hl.dsp.focus({ monitor = "HDMI-A-2" })` through `hyprctl dispatch`. The configured
output replaces `HDMI-A-2`. Legacy hyprlang and older versions remain supported.
See the official [hyprctl documentation](https://wiki.hypr.land/configuring/core/advanced-configuration/using-hyprctl/)
and [dispatchers](https://wiki.hypr.land/configuring/core/dispatchers/).
The journal identifies `DPMS` or `focus`, the command's exit status and its error
response. A zero exit status with an error response is also treated as failure.
## Invalid runtime directory ## Invalid runtime directory
`XDG_RUNTIME_DIR` must be your login-managed, user-owned directory with mode 0700 `XDG_RUNTIME_DIR` must be your login-managed, user-owned directory with mode 0700
@@ -84,6 +100,15 @@ hook manually as documented in README.
## Hardware acceptance ## Hardware acceptance
The operator confirmed successful physical HDMI input switching and recovery
with version 0.2.1 on 2026-09-23. The tested setup and the scope of that result
are recorded in [hardware validation](hardware-validation.md).
After installation, check a normal input change, a rapid away/back change and a After installation, check a normal input change, a rapid away/back change and a
logout/login cycle. Before Hyprland login there must be no chvt invocation from logout/login cycle. Before Hyprland login there must be no chvt invocation from
this service. Automated tests use mocks and do not replace this hardware check. this service. Automated tests use mocks and do not replace this hardware check.
For a first normal input-switch test with the default timing, stay on the other
input for about 40 seconds before returning. This allows the initial attempt
and its 30-second cooldown to finish. Then test rapid switching separately;
returns inside the cooldown can still be suppressed.
+38 -2
View File
@@ -44,9 +44,44 @@ monitor_present() {
jq -e --arg name "$MONITOR" 'any(.[]; .name == $name)' >/dev/null 2>&1 jq -e --arg name "$MONITOR" 'any(.[]; .name == $name)' >/dev/null 2>&1
} }
select_dispatch_syntax() {
local status provider
DISPATCH_SYNTAX=hyprlang
# The active config provider, not the version, determines dispatch syntax.
# Older Hyprland versions do not expose `status`; their dispatch is legacy.
if status="$(timeout 5 hyprctl -j status 2>/dev/null)" &&
provider="$(jq -er '.configProvider | select(type == "string")' <<< "$status" 2>/dev/null)"; then
case "$provider" in
lua|hyprlang) DISPATCH_SYNTAX="$provider" ;;
*) log "Unsupported Hyprland config provider: $provider"; return 1 ;;
esac
else
log 'Hyprland config provider unavailable; trying legacy dispatch syntax'
fi
log "Using $DISPATCH_SYNTAX dispatch syntax"
}
dispatch_checked() {
local action="$1" reply status
shift
reply="$(timeout 5 hyprctl dispatch "$@" 2>&1)"
status=$?
# Some hyprctl versions return exit 0 even for an IPC error response.
if (( status != 0 )) || [[ "$reply" != ok ]]; then
log "Hyprland $action failed (exit $status): ${reply:-no response}"
return 1
fi
}
refresh_monitor() { refresh_monitor() {
timeout 5 hyprctl dispatch dpms on "$MONITOR" >/dev/null 2>&1 && if [[ "$DISPATCH_SYNTAX" == lua ]]; then
timeout 5 hyprctl dispatch focusmonitor "$MONITOR" >/dev/null 2>&1 # MONITOR is restricted to alphanumeric characters, underscores and '-'.
dispatch_checked DPMS "hl.dsp.dpms({ action = \"on\", monitor = \"$MONITOR\" })" &&
dispatch_checked focus "hl.dsp.focus({ monitor = \"$MONITOR\" })"
else
dispatch_checked DPMS dpms on "$MONITOR" &&
dispatch_checked focus focusmonitor "$MONITOR"
fi
} }
log "Recovery started for $MONITOR on VT $GRAPHICAL_VT" log "Recovery started for $MONITOR on VT $GRAPHICAL_VT"
@@ -55,6 +90,7 @@ if ! monitor_present; then
log "Hyprland IPC unavailable or configured output $MONITOR absent - recovery aborted" log "Hyprland IPC unavailable or configured output $MONITOR absent - recovery aborted"
exit 1 exit 1
fi fi
select_dispatch_syntax || exit 1
if ! refresh_monitor; then if ! refresh_monitor; then
log 'Hyprland refresh failed - recovery aborted' log 'Hyprland refresh failed - recovery aborted'
exit 1 exit 1
+72
View File
@@ -32,8 +32,35 @@ elif name == 'hyprctl':
print('invalid') print('invalid')
else: else:
print(json.dumps([{'name': os.environ.get('OUTPUT', 'HDMI-A-2')}])) print(json.dumps([{'name': os.environ.get('OUTPUT', 'HDMI-A-2')}]))
elif args == ['-j', 'status']:
if os.environ.get('OLD_HYPRLAND'):
print('unknown request')
sys.exit(int(os.environ.get('STATUS_EXIT', '0')))
print(json.dumps({'configProvider': os.environ.get('CONFIG_PROVIDER', 'hyprlang')}))
elif args[0] == 'dispatch':
monitor = os.environ.get('OUTPUT', 'HDMI-A-2')
if os.environ.get('CONFIG_PROVIDER') == 'lua':
commands = {
('dispatch', f'hl.dsp.dpms({{ action = "on", monitor = "{monitor}" }})'): 'DPMS',
('dispatch', f'hl.dsp.focus({{ monitor = "{monitor}" }})'): 'focus',
}
else: else:
commands = {
('dispatch', 'dpms', 'on', monitor): 'DPMS',
('dispatch', 'focusmonitor', monitor): 'focus',
}
action = commands.get(tuple(args))
if action is None:
print('error: invalid dispatch syntax')
sys.exit(7)
if os.environ.get('DISPATCH_FAIL') == action and (
not os.environ.get('FINAL_REFRESH_FAIL') or (state / 'away').exists()):
print(f'error: {action} rejected for {monitor}')
sys.exit(int(os.environ.get('DISPATCH_EXIT', '7')))
print('ok') print('ok')
else:
print('unexpected hyprctl call', file=sys.stderr)
sys.exit(99)
elif name == 'sleep': elif name == 'sleep':
if os.environ.get('CHANGE_SESSION'): if os.environ.get('CHANGE_SESSION'):
(state / 'inactive').touch() (state / 'inactive').touch()
@@ -105,6 +132,51 @@ class RecoveryTests(unittest.TestCase):
self.assertEqual(self.switches(), ['2', '1']) self.assertEqual(self.switches(), ['2', '1'])
self.assertIn('Recovery completed', r.stderr) self.assertIn('Recovery completed', r.stderr)
def test_lua_session_refreshes_before_and_after_vt_switch(self):
r = self.run_script(CONFIG_PROVIDER='lua')
self.assertEqual(r.returncode, 0, r.stderr)
self.assertEqual(self.switches(), ['2', '1'])
self.assertIn('Using lua dispatch syntax', r.stderr)
dispatches = [a for a in self.calls('hyprctl') if a[0] == 'dispatch']
self.assertEqual(dispatches, [
['dispatch', 'hl.dsp.dpms({ action = "on", monitor = "HDMI-A-2" })'],
['dispatch', 'hl.dsp.focus({ monitor = "HDMI-A-2" })'],
] * 2)
def test_legacy_without_status_command(self):
for code in ('0', '1'):
with self.subTest(code=code):
r = self.run_script(OLD_HYPRLAND='1', STATUS_EXIT=code)
self.assertEqual(r.returncode, 0, r.stderr)
self.assertIn('Using hyprlang dispatch syntax', r.stderr)
def test_unknown_config_provider_aborts(self):
r = self.run_script(CONFIG_PROVIDER='unknown')
self.assertNotEqual(r.returncode, 0)
self.assertIn('Unsupported Hyprland config provider', r.stderr)
self.assertEqual(self.switches(), [])
def test_dispatch_errors_reported_before_switch(self):
for provider in ('lua', 'hyprlang'):
for action in ('DPMS', 'focus'):
for code in ('0', '7', '124'):
with self.subTest(provider=provider, action=action, code=code):
r = self.run_script(CONFIG_PROVIDER=provider,
DISPATCH_FAIL=action, DISPATCH_EXIT=code)
self.assertNotEqual(r.returncode, 0)
self.assertIn(f'Hyprland {action} failed (exit {code})', r.stderr)
self.assertIn(f'error: {action} rejected for HDMI-A-2', r.stderr)
self.assertNotIn('Recovery completed', r.stderr)
self.assertEqual(self.switches(), [])
def test_final_lua_refresh_failure_not_reported_as_success(self):
r = self.run_script(CONFIG_PROVIDER='lua', DISPATCH_FAIL='focus', FINAL_REFRESH_FAIL='1')
self.assertNotEqual(r.returncode, 0)
self.assertEqual(self.switches(), ['2', '1'])
self.assertIn('Final Hyprland refresh failed', r.stderr)
self.assertIn('error: focus rejected for HDMI-A-2', r.stderr)
self.assertNotIn('Recovery completed', r.stderr)
def test_invalid_vt(self): def test_invalid_vt(self):
for vt in ('', '0', '64', '01', 'word', '99999999999999999'): for vt in ('', '0', '64', '01', 'word', '99999999999999999'):
with self.subTest(vt=vt): with self.subTest(vt=vt):