171 lines
5.9 KiB
Python
171 lines
5.9 KiB
Python
#!/usr/bin/env python3
|
|
"""Restore this RK3528 terminal's verified HDMI timing after X11 hotplug."""
|
|
|
|
import argparse
|
|
import fcntl
|
|
import os
|
|
from pathlib import Path
|
|
import re
|
|
import signal
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
from dataclasses import dataclass
|
|
|
|
OUTPUT = "HDMI-1"
|
|
MODE = "2560x1440_60_fixed297"
|
|
# The vendor PHY rejects the EDID's 241.70 MHz timing. Keep the verified PLL.
|
|
TIMING = "297.00 2560 2608 2640 2720 1440 1443 1448 1820 +HSync -VSync".split()
|
|
POLL_SECONDS = 2
|
|
SETTLE_SECONDS = 2
|
|
|
|
|
|
def log(message):
|
|
print(time.strftime("%Y-%m-%d %H:%M:%S"), message, flush=True)
|
|
|
|
|
|
def xrandr(*args):
|
|
result = subprocess.run(["xrandr", *args], capture_output=True, text=True,
|
|
timeout=10, check=False)
|
|
if result.returncode:
|
|
raise RuntimeError((result.stderr or result.stdout).strip()[:400])
|
|
return result.stdout
|
|
|
|
|
|
@dataclass
|
|
class DisplayState:
|
|
connected: bool
|
|
geometry: str
|
|
active_mode: str
|
|
modes: set
|
|
defined: bool
|
|
|
|
@property
|
|
def healthy(self):
|
|
return (self.connected and self.geometry == "2560x1440+0+0"
|
|
and self.active_mode == MODE)
|
|
|
|
|
|
def parse_state(query):
|
|
connected, geometry, active = False, "", ""
|
|
modes, defined, in_output = set(), False, False
|
|
for line in query.splitlines():
|
|
fields = line.split()
|
|
if not fields:
|
|
continue
|
|
if fields[0] == MODE:
|
|
defined = True # Includes a globally defined but detached mode.
|
|
if not line[0].isspace():
|
|
in_output = fields[0] == OUTPUT
|
|
if in_output:
|
|
connected = len(fields) > 1 and fields[1] == "connected"
|
|
geometry = next((f for f in fields[2:]
|
|
if re.fullmatch(r"\d+x\d+\+\d+\+\d+", f)), "")
|
|
elif in_output and len(fields) > 1 and re.match(r"^\d+(?:\.\d+)?[+*]*$", fields[1]):
|
|
modes.add(fields[0])
|
|
if "*" in " ".join(fields[1:]):
|
|
active = fields[0]
|
|
return DisplayState(connected, geometry, active, modes, defined)
|
|
|
|
|
|
class Guard:
|
|
def __init__(self, runner=xrandr, reporter=log):
|
|
self.run = runner
|
|
self.log = reporter
|
|
self.bad_since = None
|
|
self.next_retry = 0
|
|
self.failures = 0
|
|
self.last_status = None
|
|
|
|
def report(self, status):
|
|
if status != self.last_status:
|
|
self.log(status)
|
|
self.last_status = status
|
|
|
|
def step(self, now):
|
|
try:
|
|
# Xorg already receives DRM hotplug events. Read its current state
|
|
# without probing every EDID/mode: the vendor driver logs clock
|
|
# warnings for those probes, adding unnecessary work while streaming.
|
|
state = parse_state(self.run("--current"))
|
|
except (OSError, RuntimeError, subprocess.TimeoutExpired) as error:
|
|
self.bad_since = None
|
|
self.report("X11 unavailable; waiting: " + str(error)[:400])
|
|
return False
|
|
if not state.connected:
|
|
self.bad_since = None
|
|
self.next_retry = 0
|
|
self.failures = 0
|
|
self.report(OUTPUT + " disconnected; no mode changes")
|
|
return False
|
|
if state.healthy:
|
|
self.bad_since = None
|
|
self.next_retry = 0
|
|
self.failures = 0
|
|
self.report("healthy: " + MODE)
|
|
return True
|
|
self.report("mode drift: " + (state.active_mode or "off") + " " + state.geometry)
|
|
if self.bad_since is None:
|
|
self.bad_since = now
|
|
if now - self.bad_since < SETTLE_SECONDS or now < self.next_retry:
|
|
return False
|
|
try:
|
|
if not state.defined:
|
|
self.run("--newmode", MODE, *TIMING)
|
|
if MODE not in state.modes:
|
|
self.run("--addmode", OUTPUT, MODE)
|
|
self.run("--output", OUTPUT, "--mode", MODE, "--pos", "0x0")
|
|
if not parse_state(self.run("--current")).healthy:
|
|
raise RuntimeError("mode was not retained after apply")
|
|
except (OSError, RuntimeError, subprocess.TimeoutExpired) as error:
|
|
self.failures = min(self.failures + 1, 6)
|
|
delay = min(60, 2 ** self.failures)
|
|
# Base backoff on completion, including a slow/timed-out modeset.
|
|
self.next_retry = time.monotonic() + delay
|
|
self.log("restore failed; retry in %ds: %s" % (delay, str(error)[:400]))
|
|
return False
|
|
self.bad_since = None
|
|
self.next_retry = 0
|
|
self.failures = 0
|
|
self.report("restored: " + MODE + " (stream process untouched)")
|
|
return True
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--once", action="store_true",
|
|
help="initial display recovery with a 20s retry window, then exit")
|
|
args = parser.parse_args()
|
|
os.environ.setdefault("DISPLAY", ":0")
|
|
os.environ.setdefault("XAUTHORITY", str(Path.home() / ".Xauthority"))
|
|
runtime = Path(os.environ.get("XDG_RUNTIME_DIR", str(Path.home() / ".cache/vdi-terminal")))
|
|
runtime.mkdir(parents=True, exist_ok=True)
|
|
# Same lock for startup and the systemd service. Never run competing guards.
|
|
with (runtime / "vdi-display-guard.lock").open("a") as lock:
|
|
try:
|
|
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
except BlockingIOError:
|
|
log("display guard already running")
|
|
return 0
|
|
stop = False
|
|
|
|
def request_stop(signum, frame):
|
|
nonlocal stop
|
|
stop = True
|
|
|
|
signal.signal(signal.SIGTERM, request_stop)
|
|
signal.signal(signal.SIGINT, request_stop)
|
|
guard = Guard()
|
|
deadline = time.monotonic() + 20
|
|
while not stop:
|
|
if guard.step(time.monotonic()) and args.once:
|
|
return 0
|
|
if args.once and time.monotonic() >= deadline:
|
|
return 1
|
|
time.sleep(POLL_SECONDS)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|