功能:完善2K串流、显示恢复与输入优化

This commit is contained in:
songyiming committed 2026-11-03 13:43:50 +08:00
1 parent e47b01beec
commit 938ec97cce
95 files changed
+5788 -72

No files matched your search

@@ -0,0 +1,170 @@
#!/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())
+22 -20
View File
@@ -3,26 +3,28 @@
export DISPLAY="${DISPLAY:-:0}"
export XAUTHORITY="${XAUTHORITY:-/home/armbian/.Xauthority}"
# Match the local output to the 2K Moonlight stream.
# This kernel rejects the monitor's 241.70 MHz timing but accepts the 297 MHz
# PLL rate. Longer vertical blanking produces 2560x1440 at approximately 60 Hz.
# HDMI can appear a few seconds after the desktop autostart entry runs.
: >/tmp/vdi-display-mode.log
mode="2560x1440_60_fixed297"
for _ in $(seq 1 20); do
if xrandr --query 2>>/tmp/vdi-display-mode.log | grep -q '^HDMI-1 connected'; then
xrandr --newmode "$mode" 297.00 \
2560 2608 2640 2720 1440 1443 1448 1820 \
+HSync -VSync >>/tmp/vdi-display-mode.log 2>&1 || true
xrandr --addmode HDMI-1 "$mode" >>/tmp/vdi-display-mode.log 2>&1 || true
if xrandr --output HDMI-1 --mode "$mode" >>/tmp/vdi-display-mode.log 2>&1; then
break
fi
xrandr --output HDMI-1 --mode 1920x1080 --rate 60 \
>>/tmp/vdi-display-mode.log 2>&1 || true
fi
sleep 1
done
# Optional verified rendering deployment. Parse an allowlist as data; never
# source/eval configuration. Explicit environment overrides the saved defaults.
render_conf="${VDI_RENDER_CONF:-$HOME/.config/vdi-terminal/render.conf}"
if [ -r "$render_conf" ]; then
while IFS='=' read -r key value || [ -n "$key" ]; do
case "$key" in
profile) export VDI_STREAM_PROFILE="${VDI_STREAM_PROFILE:-$value}" ;;
stats) export VDI_STREAM_STATS="${VDI_STREAM_STATS:-$value}" ;;
program) export VDI_MOONLIGHT_PROGRAM="${VDI_MOONLIGHT_PROGRAM:-$value}" ;;
decoder) export VDI_MOONLIGHT_DECODER="${VDI_MOONLIGHT_DECODER:-$value}" ;;
codec) export VDI_MOONLIGHT_CODEC="${VDI_MOONLIGHT_CODEC:-$value}" ;;
esac
done < "$render_conf"
fi
# Use the same idempotent recovery for boot and HDMI hotplug. The user service
# supervises ongoing recovery; a singleton lock prevents competing instances.
"$(dirname "$0")/vdi-display-guard" --once >>/tmp/vdi-display-mode.log 2>&1 || true
if ! systemctl --user start vdi-display-guard.service >>/tmp/vdi-display-mode.log 2>&1; then
echo 'WARNING: display guard service unavailable; only boot recovery active' \
>>/tmp/vdi-display-mode.log
fi
exec /home/armbian/vdi-terminal/bin/vdi-kiosk-monitor -- \
/home/armbian/vdi-terminal/bin/kiosk-shell
+54 -7
View File
@@ -3,7 +3,7 @@
#
# 职责:
# 1. 读取本地配置(主机、应用、分辨率、帧率、码率、编解码)
# 2. 以软件解码拉起 moonlight-qt,全屏串流云桌面
# 2. 按配置拉起 Moonlight,全屏串流云桌面
# 3. 写心跳文件,供 vdi-kiosk-monitor 判断是否假死
# 4. 检测串流假死(CPU 与网络均无流量)并杀掉重连
#
@@ -12,7 +12,8 @@
#
# 说明:RK3528 当前没有可用的 V4L2 request/m2m 硬解设备,moonlight-qt 的
# --video-decoder auto 会选到 h264_v4l2m2m 并报 "Could not find a valid device",
# 因此配置里显式指定 decoder=software。接入 MPP/VPU 硬解后再改回 auto。
# 因此系统客户端默认 decoder=software;验证后的独立 RKMPP 客户端
# 通过 VDI_MOONLIGHT_PROGRAM 与 decoder=hardware 显式启用。
set -u
@@ -38,9 +39,10 @@ DECODER="software"
DISPLAY_MODE="fullscreen"
AUDIO=1
ABSOLUTE_MOUSE=1
VSYNC=0
FRAME_PACING=0
VSYNC=1
FRAME_PACING=1
PERFORMANCE_OVERLAY=0
PROFILE=""
RETRY=3
RETRY_DELAY=5
HANG_TIMEOUT=90
@@ -76,6 +78,7 @@ load_config() {
vsync) VSYNC="$value" ;;
frame-pacing) FRAME_PACING="$value" ;;
performance-overlay) PERFORMANCE_OVERLAY="$value" ;;
profile) PROFILE="$value" ;;
retry) RETRY="$value" ;;
retry-delay) RETRY_DELAY="$value" ;;
hang-timeout) HANG_TIMEOUT="$value" ;;
@@ -84,6 +87,33 @@ load_config() {
done < "$CONF"
}
apply_profile() {
case "$PROFILE" in
smooth)
VSYNC=1
FRAME_PACING=1
;;
low-latency)
VSYNC=0
FRAME_PACING=0
;;
diagnostic)
VSYNC=1
FRAME_PACING=1
PERFORMANCE_OVERLAY=1
;;
"")
# 兼容旧配置:没有profile时保留moonlight.conf中的显式开关。
;;
*)
log "未知串流 profile=$PROFILE,回退 smooth"
PROFILE=smooth
VSYNC=1
FRAME_PACING=1
;;
esac
}
# 默认路由出接口的累计收字节数,用于判断串流是否还有数据流入
rx_bytes() {
iface=$(awk '$2=="00000000" {print $1; exit}' /proc/net/route)
@@ -145,7 +175,7 @@ disable_screensaver() {
build_cmd() {
# moonlight-qt stream 只接受 <host> <app> 位置参数,没有 --port 选项,
# 服务端端口由 moonlight 自身按 GameStream 协议协商。
CMD="moonlight-qt stream $HOST $APP"
CMD="${VDI_MOONLIGHT_PROGRAM:-moonlight-qt} stream $HOST $APP"
CMD="$CMD --resolution ${WIDTH}x${HEIGHT}"
CMD="$CMD --fps $FPS"
CMD="$CMD --bitrate $BITRATE"
@@ -155,7 +185,7 @@ build_cmd() {
CMD="$CMD --keep-awake"
[ "$ABSOLUTE_MOUSE" = "1" ] && CMD="$CMD --absolute-mouse"
[ "$AUDIO" = "1" ] && CMD="$CMD --audio-on-host" || CMD="$CMD --no-audio-on-host"
# 低延迟开关:默认关闭 vsync 与 frame-pacing,削减帧队列/呈现延迟
# 按profile选择同步策略;默认保持当前稳定档。
[ "$VSYNC" = "1" ] && CMD="$CMD --vsync" || CMD="$CMD --no-vsync"
[ "$FRAME_PACING" = "1" ] && CMD="$CMD --frame-pacing" || CMD="$CMD --no-frame-pacing"
[ "$PERFORMANCE_OVERLAY" = "1" ] && CMD="$CMD --performance-overlay"
@@ -163,7 +193,7 @@ build_cmd() {
run_stream() {
build_cmd
log "启动串流: $CMD"
log "启动串流 profile=${PROFILE:-config} decoder=$DECODER codec=$CODEC vsync=$VSYNC frame-pacing=$FRAME_PACING renderer=${SDL_RENDER_DRIVER:-} video-driver=${SDL_VIDEODRIVER:-}: $CMD"
# shellcheck disable=SC2086
$CMD >> "$LOG_FILE" 2>&1 &
ML_PID=$!
@@ -220,6 +250,19 @@ stop)
esac
load_config
PROFILE="${VDI_STREAM_PROFILE:-$PROFILE}"
apply_profile
DECODER="${VDI_MOONLIGHT_DECODER:-$DECODER}"
CODEC="${VDI_MOONLIGHT_CODEC:-$CODEC}"
case "$DECODER" in
software|hardware|auto) ;;
*) log "未知解码器 $DECODER,回退 software"; DECODER=software ;;
esac
case "$CODEC" in
H.264|HEVC|AV1|auto) ;;
*) log "未知视频编码 $CODEC,回退 H.264"; CODEC=H.264 ;;
esac
PERFORMANCE_OVERLAY="${VDI_STREAM_STATS:-$PERFORMANCE_OVERLAY}"
if [ -z "$HOST" ]; then
log "配置缺失或缺少 host: $CONF"
exit 2
@@ -227,6 +270,10 @@ fi
export DISPLAY="${VDI_DISPLAY:-:0}"
export XAUTHORITY="${VDI_XAUTHORITY:-$HOME/.Xauthority}"
export SDL_RENDER_DRIVER="${VDI_SDL_RENDER_DRIVER:-opengles2}"
export SDL_VIDEO_X11_NET_WM_BYPASS_COMPOSITOR="${VDI_SDL_BYPASS_COMPOSITOR:-1}"
[ -n "${VDI_SDL_FORCE_EGL:-}" ] && export SDL_VIDEO_X11_FORCE_EGL="$VDI_SDL_FORCE_EGL"
[ -n "${VDI_SDL_VIDEODRIVER:-}" ] && export SDL_VIDEODRIVER="$VDI_SDL_VIDEODRIVER"
disable_screensaver
kill_stale
@@ -0,0 +1,20 @@
#!/bin/sh
# Select explicitly after RKMPP decode + real stream validation. Does not replace
# the distribution Moonlight or add a second cursor.
set -eu
task_root="${VDI_RKMPP_BUILD_ROOT:-$HOME/vdi-build/rkmpp-experiment}"
client="${VDI_RKMPP_CLIENT:-$task_root/build/moonlight/app/moonlight}"
if [ ! -x "$client" ] || [ ! -r /dev/mpp_service ] || [ ! -w /dev/mpp_service ]; then
echo 'RKMPP client or device access unavailable' >&2
exit 78
fi
export LD_LIBRARY_PATH="$task_root/runtime/lib:$task_root/deps/usr/lib/aarch64-linux-gnu${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
export QML2_IMPORT_PATH="$task_root/deps/usr/lib/aarch64-linux-gnu/qt5/qml${QML2_IMPORT_PATH:+:$QML2_IMPORT_PATH}"
export QT_PLUGIN_PATH="$task_root/deps/usr/lib/aarch64-linux-gnu/qt5/plugins${QT_PLUGIN_PATH:+:$QT_PLUGIN_PATH}"
export GL_IS_SLOW="${GL_IS_SLOW:-0}"
export H264_DECODER_HINT=h264_rkmpp
export HEVC_DECODER_HINT=hevc_rkmpp
# Only affects unpaced rendering in the patched client. Leave the last presented
# frame and decoder references intact; replace only waiting decoded frames.
export VDI_LATEST_FRAME="${VDI_LATEST_FRAME:-1}"
exec "$client" "$@"