From bb2691fe8f854eae7cceafd00a1f50fd0adc265e Mon Sep 17 00:00:00 2001 From: mofeng-git Date: Sun, 5 Jul 2026 16:35:44 +0800 Subject: [PATCH 01/16] =?UTF-8?q?fix:=20=E4=BB=8E=20gitee=20=E5=88=87?= =?UTF-8?q?=E6=8D=A2=E5=88=B0=20github=EF=BC=8C=E9=81=BF=E5=85=8D=20gitee?= =?UTF-8?q?=20=E5=B9=B3=E5=8F=B0=E4=B8=8D=E7=A8=B3=E5=AE=9A=E5=BD=B1?= =?UTF-8?q?=E5=93=8D=E6=9E=84=E5=BB=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build/cross/Dockerfile.arm64 | 6 ++++-- build/cross/Dockerfile.armv7 | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/build/cross/Dockerfile.arm64 b/build/cross/Dockerfile.arm64 index 624a99e0..86b79d01 100644 --- a/build/cross/Dockerfile.arm64 +++ b/build/cross/Dockerfile.arm64 @@ -15,6 +15,8 @@ ARG LIBYUV_REV=957f295ea946cbbd13fcfc46e7066f2efa801233 ARG LIBVPX_VERSION=1.16.0 ARG X265_VERSION=3.4 ARG OPUS_VERSION=1.5.2 +ARG RKMPP_BRANCH=jellyfin-mpp +ARG RKRGA_BRANCH=jellyfin-rga ARG FFMPEG_ROCKCHIP_REV=40c412daccf08164493da0de990eb99a8948116b # Optionally use China mirrors for builds in China. @@ -252,8 +254,8 @@ RUN github_prefix="" \ RUN mkdir -p /tmp/ffmpeg-build && cd /tmp/ffmpeg-build \ && github_prefix="" \ && if [ "$CHINAMIRRO" = "1" ]; then github_prefix="${GH_PROXY%/}/"; fi \ - && git clone --depth 1 "https://gitee.com/nyanmisaka/mpp.git" rkmpp \ - && git clone --depth 1 "https://gitee.com/nyanmisaka/rga.git" rkrga \ + && git clone --depth 1 --branch ${RKMPP_BRANCH} "${github_prefix}https://github.com/nyanmisaka/mpp.git" rkmpp \ + && git clone --depth 1 --branch ${RKRGA_BRANCH} "${github_prefix}https://github.com/nyanmisaka/rk-mirrors.git" rkrga \ && git init ffmpeg-rockchip \ && cd ffmpeg-rockchip \ && git remote add origin "${github_prefix}https://github.com/nyanmisaka/ffmpeg-rockchip.git" \ diff --git a/build/cross/Dockerfile.armv7 b/build/cross/Dockerfile.armv7 index 7dbeeac4..63ab64bf 100644 --- a/build/cross/Dockerfile.armv7 +++ b/build/cross/Dockerfile.armv7 @@ -15,6 +15,8 @@ ARG LIBYUV_REV=957f295ea946cbbd13fcfc46e7066f2efa801233 ARG LIBVPX_VERSION=1.16.0 ARG X265_VERSION=3.4 ARG OPUS_VERSION=1.5.2 +ARG RKMPP_BRANCH=jellyfin-mpp +ARG RKRGA_BRANCH=jellyfin-rga ARG FFMPEG_ROCKCHIP_REV=40c412daccf08164493da0de990eb99a8948116b # Optionally use China mirrors for builds in China. @@ -241,8 +243,8 @@ RUN github_prefix="" \ RUN mkdir -p /tmp/ffmpeg-build && cd /tmp/ffmpeg-build \ && github_prefix="" \ && if [ "$CHINAMIRRO" = "1" ]; then github_prefix="${GH_PROXY%/}/"; fi \ - && git clone --depth 1 "https://gitee.com/nyanmisaka/mpp.git" rkmpp \ - && git clone --depth 1 "https://gitee.com/nyanmisaka/rga.git" rkrga \ + && git clone --depth 1 --branch ${RKMPP_BRANCH} "${github_prefix}https://github.com/nyanmisaka/mpp.git" rkmpp \ + && git clone --depth 1 --branch ${RKRGA_BRANCH} "${github_prefix}https://github.com/nyanmisaka/rk-mirrors.git" rkrga \ && git init ffmpeg-rockchip \ && cd ffmpeg-rockchip \ && git remote add origin "${github_prefix}https://github.com/nyanmisaka/ffmpeg-rockchip.git" \ From 16a65289f2855368e8049af8e2e5468b0ea06742 Mon Sep 17 00:00:00 2001 From: mofeng-git Date: Sun, 5 Jul 2026 21:15:44 +0800 Subject: [PATCH 02/16] =?UTF-8?q?feat:=20v4l2=20buffer=20=E7=94=B12?= =?UTF-8?q?=E6=94=B9=E4=B8=BA4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/video/capture/mod.rs | 2 ++ src/video/pipeline/shared.rs | 22 ++-------------------- src/video/streamer.rs | 6 ++++-- src/webrtc/webrtc_streamer.rs | 5 +++-- 4 files changed, 11 insertions(+), 24 deletions(-) diff --git a/src/video/capture/mod.rs b/src/video/capture/mod.rs index 3eb930cd..82e0a7dc 100644 --- a/src/video/capture/mod.rs +++ b/src/video/capture/mod.rs @@ -3,6 +3,8 @@ pub(crate) mod runtime; pub(crate) mod status; +pub const DEFAULT_CAPTURE_BUFFER_COUNT: u32 = 4; + #[cfg(unix)] mod linux; #[cfg(windows)] diff --git a/src/video/pipeline/shared.rs b/src/video/pipeline/shared.rs index f649797d..f0ee968c 100644 --- a/src/video/pipeline/shared.rs +++ b/src/video/pipeline/shared.rs @@ -38,7 +38,6 @@ const CSI_BRIDGE_NOSIGNAL_INTERVAL_MS: u64 = 500; const NOSIGNAL_POLL_MAX: Duration = Duration::from_secs(20); /// Throttle repeated encoding errors to avoid log flooding const ENCODE_ERROR_THROTTLE_SECS: u64 = 5; -const INVALID_MJPEG_LOG_THROTTLE_SECS: u64 = 5; static PROCESS_START: std::sync::OnceLock = std::sync::OnceLock::new(); @@ -61,6 +60,7 @@ use crate::video::frame::{FrameBuffer, FrameBufferPool, VideoFrame}; use crate::video::signal::SignalStatus; const MIN_CAPTURE_FRAME_SIZE: usize = 128; + #[cfg(all( any(target_arch = "aarch64", target_arch = "arm"), not(target_os = "android") @@ -870,8 +870,6 @@ impl SharedVideoPipeline { let mut sequence: u64 = 0; let mut consecutive_timeouts: u32 = 0; let capture_error_throttler = LogThrottler::with_secs(5); - let invalid_mjpeg_throttler = - LogThrottler::with_secs(INVALID_MJPEG_LOG_THROTTLE_SECS); let mut suppressed_capture_errors: HashMap = HashMap::new(); while pipeline.running_flag.load(Ordering::Acquire) { @@ -1232,24 +1230,8 @@ impl SharedVideoPipeline { } owned.truncate(frame_size); - if pixel_format.is_compressed() && !VideoFrame::is_valid_jpeg_bytes(&owned) { - if invalid_mjpeg_throttler.should_log("invalid_mjpeg_capture_frame") { - let b0 = owned.first().copied().unwrap_or_default(); - let b1 = owned.get(1).copied().unwrap_or_default(); - warn!( - "Dropping invalid MJPEG capture frame: size={}, starts with 0x{:02x} 0x{:02x}", - owned.len(), - b0, - b1 - ); - } - continue; - } - // Notify streaming only after frame validation passes — - // stale/warm-up frames from V4L2 kernel queues can cause - // DQBUF Ok with invalid data, which would prematurely - // clear the frontend error overlay. + // Notify streaming only after the short-frame guard passes. pipeline.notify_state(PipelineStateNotification::streaming()); let frame = Arc::new(VideoFrame::from_pooled( Arc::new(FrameBuffer::new(owned, Some(buffer_pool.clone()))), diff --git a/src/video/streamer.rs b/src/video/streamer.rs index e0463c90..31c57233 100644 --- a/src/video/streamer.rs +++ b/src/video/streamer.rs @@ -27,7 +27,9 @@ use crate::video::capture::status::{ capture_error_log_key, classify_capture_io_error, signal_status_from_capture_kind, CaptureIoErrorKind, }; -use crate::video::capture::{is_source_changed_error, BridgeContext, CaptureStream}; +use crate::video::capture::{ + is_source_changed_error, BridgeContext, CaptureStream, DEFAULT_CAPTURE_BUFFER_COUNT, +}; const MIN_CAPTURE_FRAME_SIZE: usize = 128; @@ -769,7 +771,7 @@ impl Streamer { const MAX_RETRIES: u32 = 5; const RETRY_DELAY_MS: u64 = 200; const IDLE_STOP_DELAY_SECS: u64 = 5; - const BUFFER_COUNT: u32 = 2; + const BUFFER_COUNT: u32 = DEFAULT_CAPTURE_BUFFER_COUNT; /// Initial back-off after signal loss before the first soft restart. /// /// PiKVM/ustreamer drops to sub-second recovery because it subscribes to diff --git a/src/webrtc/webrtc_streamer.rs b/src/webrtc/webrtc_streamer.rs index a0799b21..8a1fd2c6 100644 --- a/src/webrtc/webrtc_streamer.rs +++ b/src/webrtc/webrtc_streamer.rs @@ -12,6 +12,7 @@ use crate::audio::{AudioController, OpusFrame}; use crate::error::{AppError, Result}; use crate::events::{EventBus, StreamDeviceLostKind, SystemEvent}; use crate::hid::HidController; +use crate::video::capture::DEFAULT_CAPTURE_BUFFER_COUNT; use crate::video::codec::h264_bitstream; use crate::video::device::{ enumerate_devices, select_recovery_device, VideoDevice, VideoDeviceRecoveryHint, @@ -319,7 +320,7 @@ impl WebRtcStreamer { current_capture .as_ref() .map(|capture| capture.buffer_count) - .unwrap_or(2), + .unwrap_or(DEFAULT_CAPTURE_BUFFER_COUNT), ) }; @@ -745,7 +746,7 @@ impl WebRtcStreamer { }); *self.capture_device.write().await = Some(CaptureDeviceConfig { device_path, - buffer_count: 2, + buffer_count: DEFAULT_CAPTURE_BUFFER_COUNT, jpeg_quality, subdev_path, bridge_kind, From b346af35d3833eaa1cad06da0b48a0423aced8b5 Mon Sep 17 00:00:00 2001 From: mofeng-git Date: Sun, 5 Jul 2026 21:20:23 +0800 Subject: [PATCH 03/16] =?UTF-8?q?feat:=20VNC=E3=80=81RTSP=20=E6=9C=8D?= =?UTF-8?q?=E5=8A=A1=E6=94=AF=E6=8C=81=E7=9B=91=E5=90=AC=20ipv6=20?= =?UTF-8?q?=E5=9C=B0=E5=9D=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/rtsp/service.rs | 12 +++-- src/utils/mod.rs | 35 ++++++++++++++ src/vnc/mod.rs | 12 +++-- src/web/handlers/config/types.rs | 78 ++++++++++++++++++++++++++++++++ 4 files changed, 131 insertions(+), 6 deletions(-) diff --git a/src/rtsp/service.rs b/src/rtsp/service.rs index f7a754cb..0e0897cc 100644 --- a/src/rtsp/service.rs +++ b/src/rtsp/service.rs @@ -8,6 +8,7 @@ use tokio::sync::{broadcast, Mutex, RwLock}; use crate::config::RtspConfig; use crate::error::{AppError, Result}; +use crate::utils::{bind_socket_addr, bind_tcp_listener}; use crate::video::VideoStreamManager; use super::auth::{extract_basic_auth, rtsp_auth_credentials}; @@ -73,13 +74,18 @@ impl RtspService { tracing::debug!("Failed to request keyframe on RTSP start: {}", err); } - let bind_addr: SocketAddr = format!("{}:{}", config.bind, config.port) - .parse() + let bind_addr = bind_socket_addr(&config.bind, config.port) .map_err(|e| AppError::BadRequest(format!("Invalid RTSP bind address: {}", e)))?; - let listener = TcpListener::bind(bind_addr).await.map_err(|e| { + let listener = bind_tcp_listener(bind_addr).map_err(|e| { AppError::Io(io::Error::new(e.kind(), format!("RTSP bind failed: {}", e))) })?; + let listener = TcpListener::from_std(listener).map_err(|e| { + AppError::Io(io::Error::new( + e.kind(), + format!("RTSP listener setup failed: {}", e), + )) + })?; let service_config = self.config.clone(); let video_manager = self.video_manager.clone(); diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 380ccb7f..d1fb32fe 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -15,3 +15,38 @@ pub use host::{hostname_from_etc, hostname_uname}; pub use net::{bind_tcp_listener, bind_udp_socket}; pub use serial::list_serial_ports; pub use throttle::LogThrottler; + +use std::net::{IpAddr, SocketAddr}; + +pub fn bind_socket_addr(bind: &str, port: u16) -> Result { + bind.parse::().map(|ip| SocketAddr::new(ip, port)) +} + +#[cfg(test)] +mod tests { + use super::bind_socket_addr; + + #[test] + fn ipv6_bind_socket_addr_formats_ipv4_and_ipv6() { + assert_eq!( + bind_socket_addr("0.0.0.0", 5900).unwrap().to_string(), + "0.0.0.0:5900" + ); + assert_eq!( + bind_socket_addr("127.0.0.1", 5900).unwrap().to_string(), + "127.0.0.1:5900" + ); + assert_eq!( + bind_socket_addr("::", 8554).unwrap().to_string(), + "[::]:8554" + ); + assert_eq!( + bind_socket_addr("::1", 8554).unwrap().to_string(), + "[::1]:8554" + ); + assert_eq!( + bind_socket_addr("2001:db8::1", 8554).unwrap().to_string(), + "[2001:db8::1]:8554" + ); + } +} diff --git a/src/vnc/mod.rs b/src/vnc/mod.rs index 690e500b..fb9c0716 100644 --- a/src/vnc/mod.rs +++ b/src/vnc/mod.rs @@ -17,6 +17,7 @@ use crate::config::{VncConfig, VncEncoding}; use crate::error::{AppError, Result}; use crate::hid::HidController; use crate::stream::mjpeg::ClientGuard; +use crate::utils::{bind_socket_addr, bind_tcp_listener}; use crate::video::codec::{BitratePreset, VideoCodecType}; use crate::video::stream_manager::VideoStreamManager; @@ -108,15 +109,20 @@ impl VncService { return Err(err); } - let bind_addr: SocketAddr = format!("{}:{}", config.bind, config.port) - .parse() + let bind_addr = bind_socket_addr(&config.bind, config.port) .map_err(|e| AppError::BadRequest(format!("Invalid VNC bind address: {}", e)))?; - let listener = TcpListener::bind(bind_addr).await.map_err(|e| { + let listener = bind_tcp_listener(bind_addr).map_err(|e| { AppError::Io(std::io::Error::new( e.kind(), format!("VNC bind failed: {}", e), )) })?; + let listener = TcpListener::from_std(listener).map_err(|e| { + AppError::Io(std::io::Error::new( + e.kind(), + format!("VNC listener setup failed: {}", e), + )) + })?; let config_ref = self.config.clone(); let video_manager = self.video_manager.clone(); diff --git a/src/web/handlers/config/types.rs b/src/web/handlers/config/types.rs index a6193650..b4ac5546 100644 --- a/src/web/handlers/config/types.rs +++ b/src/web/handlers/config/types.rs @@ -1335,4 +1335,82 @@ mod tests { }; assert!(update.validate().is_err()); } + + #[test] + fn ipv6_bind_vnc_config_accepts_ipv6_literals() { + for bind in ["::", "::1", "2001:db8::1"] { + let update = VncConfigUpdate { + enabled: None, + bind: Some(bind.to_string()), + port: Some(5900), + encoding: None, + jpeg_quality: None, + allow_one_client: None, + password: None, + }; + assert!( + update.validate().is_ok(), + "bind address should pass: {bind}" + ); + } + } + + #[test] + fn ipv6_bind_vnc_config_rejects_non_ip_or_socket_syntax() { + for bind in ["localhost", "127.0.0.1:5900", "[::1]:5900"] { + let update = VncConfigUpdate { + enabled: None, + bind: Some(bind.to_string()), + port: Some(5900), + encoding: None, + jpeg_quality: None, + allow_one_client: None, + password: None, + }; + assert!( + update.validate().is_err(), + "bind address should fail: {bind}" + ); + } + } + + #[test] + fn ipv6_bind_rtsp_config_accepts_ipv6_literals() { + for bind in ["::", "::1", "2001:db8::1"] { + let update = RtspConfigUpdate { + enabled: None, + bind: Some(bind.to_string()), + port: Some(8554), + path: None, + allow_one_client: None, + codec: None, + username: None, + password: None, + }; + assert!( + update.validate().is_ok(), + "bind address should pass: {bind}" + ); + } + } + + #[test] + fn ipv6_bind_rtsp_config_rejects_non_ip_or_socket_syntax() { + for bind in ["localhost", "127.0.0.1:8554", "[::1]:8554"] { + let update = RtspConfigUpdate { + enabled: None, + bind: Some(bind.to_string()), + port: Some(8554), + path: None, + allow_one_client: None, + codec: None, + username: None, + password: None, + }; + assert!( + update.validate().is_err(), + "bind address should fail: {bind}" + ); + } + } } From 696c1b4bb285b1205458c23886a726fab9b03e3a Mon Sep 17 00:00:00 2001 From: mofeng-git Date: Sun, 5 Jul 2026 21:30:11 +0800 Subject: [PATCH 04/16] =?UTF-8?q?test:=20=E5=A2=9E=E5=8A=A0=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E5=A5=97=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test/okvm-test/.gitignore | 4 + test/okvm-test/README.md | 187 ++ test/okvm-test/agent/build-windows.sh | 12 + test/okvm-test/agent/go.mod | 5 + test/okvm-test/agent/go.sum | 2 + test/okvm-test/agent/main.go | 1202 +++++++++++++ test/okvm-test/okvm_media.py | 79 + test/okvm-test/okvm_report.py | 671 +++++++ test/okvm-test/okvm_testctl.py | 2319 +++++++++++++++++++++++++ test/okvm-test/requirements.txt | 5 + 10 files changed, 4486 insertions(+) create mode 100644 test/okvm-test/.gitignore create mode 100644 test/okvm-test/README.md create mode 100755 test/okvm-test/agent/build-windows.sh create mode 100644 test/okvm-test/agent/go.mod create mode 100644 test/okvm-test/agent/go.sum create mode 100644 test/okvm-test/agent/main.go create mode 100644 test/okvm-test/okvm_media.py create mode 100644 test/okvm-test/okvm_report.py create mode 100755 test/okvm-test/okvm_testctl.py create mode 100644 test/okvm-test/requirements.txt diff --git a/test/okvm-test/.gitignore b/test/okvm-test/.gitignore new file mode 100644 index 00000000..ab8e77fa --- /dev/null +++ b/test/okvm-test/.gitignore @@ -0,0 +1,4 @@ +.venv/ +__pycache__/ +reports/ +bin/*.exe diff --git a/test/okvm-test/README.md b/test/okvm-test/README.md new file mode 100644 index 00000000..92d47e2b --- /dev/null +++ b/test/okvm-test/README.md @@ -0,0 +1,187 @@ +# One-KVM 自动化产测工具 + +工具由两部分组成: + +- `okvm_testctl.py`:Windows 本地控制端。负责 SSH 操作、One-KVM HTTP API 调用、MJPEG/WebRTC 客户端、HID 检测、MSD 检测、ATX API 检测和报告输出。`run` 子命令只支持原生 Windows,不支持 WSL/Linux。 +- `agent/`:Windows 被控机配套程序。使用 Go 编写,编译为单文件 `.exe`。 + +## 编译 Windows 配套程序 + +在 Windows PowerShell 安装 Go 后执行: + +```powershell +cd test/okvm-test/agent +New-Item -ItemType Directory -Force ..\bin +go build -o ..\bin\okvm-win-agent-amd64.exe . +``` + +编译产物输出到: + +```text +test/okvm-test/bin/okvm-win-agent-amd64.exe +``` + +将该 exe 放到被控 Windows 机器上,在开始产测前运行: + +```powershell +.\okvm-win-agent-amd64.exe -listen 0.0.0.0:8765 +``` + +Windows 配套程序会监听所有网卡地址,本地控制端通过 `--agent-host ` 主动连接它。配套程序默认隐藏测试窗口,只有视频延迟、HID 等需要采集画面/输入时才显示。 + +## 安装本地控制端依赖 + +Windows 控制端使用 PowerShell: + +```powershell +cd test/okvm-test +py -m venv .venv +.\.venv\Scripts\Activate.ps1 +pip install -r requirements.txt +python -m playwright install chrome +``` + +控制端固定使用 Google Chrome (`chrome`) 跑 WebRTC/H.265 测试,以便使用 Windows 核显/系统解码。所有 Chrome 启动都会加入 `--enable-features=WebRtcAllowH265Receive` 和 `--force-fieldtrials=WebRTC-Video-H26xPacketBuffer/Enabled`。 + +## 针对当前测试机运行 + +PowerShell: + +```powershell +cd test/okvm-test +$env:OKVM_SSH_PASSWORD="1234" +python okvm_testctl.py run ` + --target 192.168.137.67 ` + --ssh-user root ` + --ssh-password-prompt ` + --reset ` + --agent-host ` + --agent-port 8765 +``` + +默认流程: + +1. 通过 SSH 停止 `one-kvm`。 +2. 备份 `/etc/one-kvm/one-kvm.db`。 +3. 删除当前数据库,实现强制重置。 +4. 启动 `one-kvm`。 +5. 采集控制端到目标机的 TCP connect 和 HTTP `/health` 往返延迟,调用初始化接口创建测试账号,登录后发现设备并应用测试配置。 +6. 启用 OTG MSD 后,默认把本仓库的 Ventoy 资源同步到目标机 `/etc/one-kvm/ventoy`,并重启一次 `one-kvm` 让资源初始化生效。 +7. 执行视频输入/输出、每个视频输入的 MJPEG 端到端画面延迟、HDMI 画面确认、颜色偏差、HID、MSD、ATX API 和性能检测。 +8. 输出中文完整报告 `reports//report-YYYYMMDD-HHMMSS.md` 和面向用户的迷你摘要 `reports//user-report-YYYYMMDD-HHMMSS.md`,截图、日志和采集帧保存在 `reports//evidence/`。 + +终端结果默认使用颜色区分:绿色为 `PASS`,黄色为 `WARN/SKIP`,红色为 `FAIL`。可通过 `--no-color` 或 `NO_COLOR=1` 关闭颜色。 + +## 视频测试规则 + +控制端会先通过 SSH 执行 `lsusb -t`,再结合 `/api/devices` 自动选择视频输入: + +- USB2.0 采集卡:测试 `1080p30 MJPEG`,再切换 `1080p YUYV` 并选择该分辨率最高帧率;如果没有 1080p YUYV,才退到不超过 1080p 的最高分辨率。 +- USB3.0 采集卡:测试 `1080p60 MJPEG`,再切换 `1080p YUYV` 并选择该分辨率最高帧率;如果没有 1080p YUYV,才退到不超过 1080p 的最高分辨率。 +- CSI/MIPI:只测试一套 `1080p60 NV12`,不做输入格式切换。 + +每个输入配置都会跑三种输出: + +- MJPEG/HTTP +- H.264 WebRTC +- H.265 WebRTC + +默认每个视频输出模式采样 30 秒;可通过 `--sample-seconds <秒数>` 覆盖。 + +MJPEG/HTTP 测试时,控制端会让 Windows agent 输出默认 60fps 的全屏动态画面,避免静态画面触发 MJPEG “无变化不发帧”策略导致 fps 误判;可通过 `--mjpeg-motion-fps ` 覆盖。 + +如果浏览器或编码器不支持 H.265,会记录为 `SKIP`。 + +默认情况下,视频链路只要实际 fps 大于 0 就视为功能成功;报告保留实际 fps、RTT 和 jitter。需要把低帧率作为失败时,加 `--strict-performance`。 + +## HDMI 采集与视频延迟测试 + +连接 Windows agent 后,控制端会让 Windows 配套程序打开全屏纯色窗口,然后通过 One-KVM 的 MJPEG/HTTP 或浏览器 WebRTC 解码画面抓帧检测: + +- `hdmi_identity`:确认 HDMI 采集画面确实跟随被控 Windows 画面变化,至少验证红、绿、蓝三种高饱和颜色。 +- `hdmi_color_range`:采样画面中心区域,输出期望 RGB、实测 RGB 均值、标准差、平均误差和最大通道误差。 +- `video_latency_<输入>_mjpeg` / `video_latency_<输入>_h264` / `video_latency_<输入>_h265`:每个被选中的视频输入 case 都会分别执行 MJPEG、H.264、H.265 延迟测试。Windows agent 延迟切换纯色,Windows 控制端从 MJPEG 帧或 Chrome 解码后的 WebRTC video 帧中检测首次颜色变化,输出端到端视觉延迟 p50、p95、max。延迟测试不保存命中帧截图。 + +纯色采样不会再用第一帧直接判定;控制端会在超时时间内持续拉取 MJPEG,等待目标颜色出现。若始终未匹配,会保存最佳接近帧,并在 Markdown 报告中写出实测 RGB、未命中数量和采样帧数。 + +默认颜色误差阈值: + +- 平均 RGB 误差 `<=30`:`PASS` +- 平均 RGB 误差 `<=60`:`WARN` +- 平均 RGB 误差 `>60`:`FAIL` + +默认视频视觉延迟阈值只用于基本功能判定: + +- p95 `<=3000ms`:`PASS` +- p95 `>3000ms` 或未检测到目标颜色:`FAIL` + +相关开关: + +- `--no-hdmi-tests`:跳过 HDMI 画面、颜色和视频视觉延迟测试。 +- `--hdmi-capture-timeout <秒>`:单个纯色采样等待目标颜色的最长时间,默认 8 秒。 +- `--hdmi-match-frames <帧数>`:目标颜色需要连续匹配的帧数,默认 1;静态 MJPEG 场景建议保持默认。 +- `--hdmi-color-warn ` / `--hdmi-color-fail `:调整颜色偏差阈值。 +- `--video-latency-trials <次数>`:调整切色延迟测试次数,默认 5 次。旧名 `--hdmi-latency-trials` 仍兼容。 +- `--video-latency-fail-ms `:调整基本功能阈值,默认 3000ms。旧名 `--hdmi-latency-fail-ms` 仍兼容。 + +WebRTC 性能表仍保留连接 RTT/jitter;真实画面级延迟会额外按 MJPEG、H.264、H.265 逐输入输出。 + +## 网页截图证据 + +默认保留关键过程网页截图到 `reports//evidence/screenshots/`: + +- 登录后控制台首页。 +- MJPEG 模式网页截图。 +- H.264 WebRTC 模式网页截图。 +- H.265 WebRTC 尝试网页截图。 +- HID/MSD/ATX 测试后的控制台状态。 + +视频模式截图会等待页面脱离“等待首帧/连接中”等过渡状态后再保存;H.265 不支持等真实失败页面会直接保留作为证据。等待时间可用 `--screenshot-wait-ms ` 调整,默认 6000ms。 + +如需跳过网页截图,可加 `--no-screenshots`。 + +## HID 测试 + +HID 测试依赖 Windows agent。流程如下: + +1. `/hid/status` 确认 HID 后端可用。 +2. 键盘矩阵覆盖全部字母、数字、F1-F12,以及不影响系统运行的 Ctrl/Shift + 功能键组合。 +3. 鼠标矩阵覆盖绝对坐标移动和相对移动。 +4. 键盘鼠标矩阵完成后,使用 F8 做 HID 延迟采样,输出 p50、p95、max。 + +HID 延迟默认 5 次采样,可通过 `--hid-latency-trials <次数>` 调整。默认 p95 `<=80ms` 为 `PASS`,`<=200ms` 为 `WARN`,更高为 `FAIL`;阈值可用 `--hid-latency-warn-ms ` 和 `--hid-latency-fail-ms ` 调整。 + +## MSD 测试 + +MSD 测试依赖 OTG。流程如下: + +1. 确认目标机 `/etc/one-kvm/ventoy` 存在 `boot.img`、`core.img`、`ventoy.disk.img`。 +2. 如果资源缺失,默认从 `libs/ventoy-img-rs/resources` 解压并通过 SSH/SFTP 同步到目标机。 +3. 启用 MSD 后重启 `one-kvm`,确保 Ventoy 资源在服务进程中初始化。 +4. 通过 `/api/msd/drive/init` 创建小型虚拟盘。 +5. 通过 `/api/msd/connect {"mode":"drive"}` 连接到 Windows。 +6. Windows agent 等待新盘符出现。 +7. Windows agent 写入测试文件、同步到虚拟盘、优先用未缓存读取读回并校验 SHA-256,同时输出简单写入/读取速度。若 Windows/驱动不支持未缓存读取,会退回缓存读取并在报告中标为“仅校验”,不作为真实读盘速度。 +8. 控制端断开 MSD,Windows agent 确认盘符消失。 + +相关开关: + +- `--ventoy-resources-dir `:指定本地 Ventoy 资源目录。 +- `--no-ventoy-sync`:不向目标机同步 Ventoy 资源。 +- `--no-msd-restart-after-enable`:启用 MSD 后不重启 `one-kvm`。 + +## ATX 测试 + +默认不执行真实电源按键动作,因为当前测试环境不会连接真实 ATX 设备。 + +默认只验证: + +- `/api/atx/status` +- `/api/config/atx` +- `/api/atx/wol` + +## 注意事项 + +- `--reset` 会删除 One-KVM 当前 SQLite 数据库,虽然会先备份,但这是破坏性操作。 +- 旧数据库不会自动恢复,备份保存在目标机 `/etc/one-kvm/test-backups//`。 +- 编译出的 `.exe` 和测试报告默认被 `.gitignore` 忽略。 diff --git a/test/okvm-test/agent/build-windows.sh b/test/okvm-test/agent/build-windows.sh new file mode 100755 index 00000000..fa4a0782 --- /dev/null +++ b/test/okvm-test/agent/build-windows.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd "$(dirname "$0")" + +arch="${GOARCH:-amd64}" +out="../bin/okvm-win-agent-${arch}.exe" + +GOOS=windows GOARCH="$arch" CGO_ENABLED=0 \ + go build -trimpath -ldflags "-s -w" -o "$out" . + +echo "Built $out" diff --git a/test/okvm-test/agent/go.mod b/test/okvm-test/agent/go.mod new file mode 100644 index 00000000..cb26cc13 --- /dev/null +++ b/test/okvm-test/agent/go.mod @@ -0,0 +1,5 @@ +module okvm-win-agent + +go 1.22 + +require github.com/gorilla/websocket v1.5.3 diff --git a/test/okvm-test/agent/go.sum b/test/okvm-test/agent/go.sum new file mode 100644 index 00000000..25a9fc4b --- /dev/null +++ b/test/okvm-test/agent/go.sum @@ -0,0 +1,2 @@ +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= diff --git a/test/okvm-test/agent/main.go b/test/okvm-test/agent/main.go new file mode 100644 index 00000000..f9f05127 --- /dev/null +++ b/test/okvm-test/agent/main.go @@ -0,0 +1,1202 @@ +//go:build windows + +package main + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "flag" + "fmt" + "log" + "math/rand" + "net/http" + "os" + "path/filepath" + "runtime" + "strings" + "sync" + "syscall" + "time" + "unsafe" + + "github.com/gorilla/websocket" +) + +const ( + wsOverlapped = 0x00000000 + wsPopup = 0x80000000 + wsVisible = 0x10000000 + wsExTopmost = 0x00000008 + wsExToolWindow = 0x00000080 + + cwUseDefault = 0x80000000 + swShow = 5 + swHide = 0 + + wmDestroy = 0x0002 + wmPaint = 0x000F + wmClose = 0x0010 + wmKeyDown = 0x0100 + wmKeyUp = 0x0101 + wmChar = 0x0102 + wmSysKeyDown = 0x0104 + wmSysKeyUp = 0x0105 + wmTimer = 0x0113 + wmMouseMove = 0x0200 + wmLButtonDown = 0x0201 + wmLButtonUp = 0x0202 + wmRButtonDown = 0x0204 + wmRButtonUp = 0x0205 + wmMouseWheel = 0x020A + + vkLShift = 0xA0 + vkRShift = 0xA1 + vkLControl = 0xA2 + vkRControl = 0xA3 + vkLMenu = 0xA4 + vkRMenu = 0xA5 + vkLWin = 0x5B + vkRWin = 0x5C + + modLeftCtrl = 0x01 + modLeftShift = 0x02 + modLeftAlt = 0x04 + modLeftMeta = 0x08 + modRightCtrl = 0x10 + modRightShift = 0x20 + modRightAlt = 0x40 + modRightMeta = 0x80 + + wmApp = 0x8000 + wmAppInvalidate = wmApp + 1 + wmAppShow = wmApp + 2 + wmAppHide = wmApp + 3 + wmAppFocus = wmApp + 4 + wmAppDynamicStart = wmApp + 5 + wmAppDynamicStop = wmApp + 6 + + dynamicTimerID = 1 + + colorWindow = 5 + + driveUnknown = 0 + driveNoRootDir = 1 + driveRemovable = 2 + driveFixed = 3 + + genericRead = 0x80000000 + fileShareRead = 0x00000001 + fileShareWrite = 0x00000002 + openExisting = 3 + fileAttributeNormal = 0x00000080 + fileFlagNoBuffering = 0x20000000 + fileFlagSequentialScan = 0x08000000 +) + +var ( + user32 = syscall.NewLazyDLL("user32.dll") + kernel32 = syscall.NewLazyDLL("kernel32.dll") + gdi32 = syscall.NewLazyDLL("gdi32.dll") + imm32 = syscall.NewLazyDLL("imm32.dll") + + procRegisterClassExW = user32.NewProc("RegisterClassExW") + procCreateWindowExW = user32.NewProc("CreateWindowExW") + procDefWindowProcW = user32.NewProc("DefWindowProcW") + procDispatchMessageW = user32.NewProc("DispatchMessageW") + procGetMessageW = user32.NewProc("GetMessageW") + procTranslateMessage = user32.NewProc("TranslateMessage") + procPostMessageW = user32.NewProc("PostMessageW") + procPostQuitMessage = user32.NewProc("PostQuitMessage") + procShowWindow = user32.NewProc("ShowWindow") + procSetForegroundWindow = user32.NewProc("SetForegroundWindow") + procGetSystemMetrics = user32.NewProc("GetSystemMetrics") + procInvalidateRect = user32.NewProc("InvalidateRect") + procUpdateWindow = user32.NewProc("UpdateWindow") + procSetTimer = user32.NewProc("SetTimer") + procKillTimer = user32.NewProc("KillTimer") + procGetKeyState = user32.NewProc("GetKeyState") + procBeginPaint = user32.NewProc("BeginPaint") + procEndPaint = user32.NewProc("EndPaint") + procFillRect = user32.NewProc("FillRect") + procCreateSolidBrush = gdi32.NewProc("CreateSolidBrush") + procDeleteObject = gdi32.NewProc("DeleteObject") + procImmAssociateContext = imm32.NewProc("ImmAssociateContext") + procGetModuleHandleW = kernel32.NewProc("GetModuleHandleW") + procQueryPerformanceCount = kernel32.NewProc("QueryPerformanceCounter") + procQueryPerformanceFreq = kernel32.NewProc("QueryPerformanceFrequency") + procGetLogicalDrives = kernel32.NewProc("GetLogicalDrives") + procGetDriveTypeW = kernel32.NewProc("GetDriveTypeW") + procGetVolumeInformationW = kernel32.NewProc("GetVolumeInformationW") + procGetDiskFreeSpaceExW = kernel32.NewProc("GetDiskFreeSpaceExW") + procGetDiskFreeSpaceW = kernel32.NewProc("GetDiskFreeSpaceW") + procCreateFileW = kernel32.NewProc("CreateFileW") + procReadFile = kernel32.NewProc("ReadFile") + procCloseHandle = kernel32.NewProc("CloseHandle") + procSetProcessDPIAware = user32.NewProc("SetProcessDPIAware") +) + +type wndClassEx struct { + Size uint32 + Style uint32 + WndProc uintptr + ClsExtra int32 + WndExtra int32 + Instance uintptr + Icon uintptr + Cursor uintptr + Background uintptr + MenuName *uint16 + ClassName *uint16 + IconSm uintptr +} + +type point struct { + X int32 + Y int32 +} + +type msg struct { + HWnd uintptr + Message uint32 + WParam uintptr + LParam uintptr + Time uint32 + Pt point +} + +type rect struct { + Left int32 + Top int32 + Right int32 + Bottom int32 +} + +type paintStruct struct { + Hdc uintptr + Erase int32 + RcPaint rect + Restore int32 + IncUpdate int32 + RgbReserved [32]byte +} + +type appState struct { + mu sync.Mutex + hwnd uintptr + bgColor uint32 + colorHex string + lastColorChangeQpc int64 + lastColorChangeUnix int64 + colorSequence int64 + dynamicActive bool + dynamicFPS int + dynamicFrame int64 + events []hidEvent +} + +type hidEvent struct { + Type string `json:"type"` + Code uint32 `json:"code,omitempty"` + Char string `json:"char,omitempty"` + Modifiers uint32 `json:"modifiers,omitempty"` + X int32 `json:"x,omitempty"` + Y int32 `json:"y,omitempty"` + WheelDelta int16 `json:"wheel_delta,omitempty"` + Qpc int64 `json:"qpc"` + UnixNano int64 `json:"unix_nano"` + Description string `json:"description,omitempty"` +} + +type command struct { + ID string `json:"id"` + Command string `json:"command"` + Payload json.RawMessage `json:"payload"` +} + +type response struct { + ID string `json:"id"` + OK bool `json:"ok"` + Type string `json:"type,omitempty"` + Payload interface{} `json:"payload,omitempty"` + Error string `json:"error,omitempty"` +} + +type driveInfo struct { + Letter string `json:"letter"` + Root string `json:"root"` + Type uint32 `json:"type"` + Label string `json:"label,omitempty"` + FileSystem string `json:"file_system,omitempty"` + TotalBytes uint64 `json:"total_bytes,omitempty"` + FreeBytes uint64 `json:"free_bytes,omitempty"` + Removable bool `json:"removable"` + WriteCapable bool `json:"write_capable"` + ObservedNano int64 `json:"observed_unix_nano"` +} + +var state = &appState{ + bgColor: 0x00101010, + colorHex: "#101010", + lastColorChangeQpc: qpcNow(), + lastColorChangeUnix: time.Now().UnixNano(), +} + +func main() { + listen := flag.String("listen", "0.0.0.0:8765", "listen address") + noWindow := flag.Bool("no-window", true, "start with the test window hidden; commands show it when needed") + flag.Parse() + + procSetProcessDPIAware.Call() + + uiReady := make(chan struct{}) + go runUI(uiReady, *noWindow) + <-uiReady + + log.Printf("Windows agent listening on ws://%s/agent", *listen) + if err := serveAgent(*listen); err != nil { + log.Fatal(err) + } +} + +var upgrader = websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { + return true + }, +} + +func serveAgent(listen string) error { + http.HandleFunc("/agent", handleAgentWebSocket) + return http.ListenAndServe(listen, nil) +} + +func handleAgentWebSocket(w http.ResponseWriter, r *http.Request) { + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + log.Printf("websocket upgrade failed: %v", err) + return + } + defer conn.Close() + + hello := map[string]interface{}{ + "type": "hello", + "hostname": hostname(), + "qpc_freq": qpcFreq(), + "drives": listDrives(), + "screen": screenInfo(), + "agent_time": time.Now().UnixNano(), + } + if err := conn.WriteJSON(hello); err != nil { + log.Printf("failed to send hello: %v", err) + return + } + + for { + var cmd command + if err := conn.ReadJSON(&cmd); err != nil { + log.Printf("agent client disconnected: %v", err) + return + } + resp := handleCommand(cmd) + if err := conn.WriteJSON(resp); err != nil { + log.Printf("failed to send command response: %v", err) + return + } + } +} + +func handleCommand(cmd command) response { + defer func() { + if r := recover(); r != nil { + log.Printf("panic while handling %s: %v", cmd.Command, r) + } + }() + + switch cmd.Command { + case "ping": + return ok(cmd, map[string]interface{}{ + "qpc": qpcNow(), + "unix_nano": time.Now().UnixNano(), + }) + case "show": + var p struct { + Color string `json:"color"` + Full bool `json:"full"` + } + _ = json.Unmarshal(cmd.Payload, &p) + if p.Color == "" { + p.Color = "#00ff00" + } + display := setColor(p.Color) + showWindow(true) + focusWindow() + return ok(cmd, display) + case "start_dynamic": + var p struct { + FPS int `json:"fps"` + } + _ = json.Unmarshal(cmd.Payload, &p) + if p.FPS <= 0 { + p.FPS = 60 + } + display := startDynamic(p.FPS) + showWindow(true) + focusWindow() + return ok(cmd, display) + case "stop_dynamic": + stopDynamic() + return ok(cmd, currentDisplayState()) + case "schedule_color": + var p struct { + Color string `json:"color"` + DelayMS int `json:"delay_ms"` + } + _ = json.Unmarshal(cmd.Payload, &p) + if p.Color == "" { + p.Color = "#00ff00" + } + if p.DelayMS < 0 { + p.DelayMS = 0 + } + delay := time.Duration(p.DelayMS) * time.Millisecond + scheduledUnix := time.Now().Add(delay).UnixNano() + color := normalizeColor(p.Color) + time.AfterFunc(delay, func() { + setColor(color) + showWindow(true) + }) + showWindow(true) + focusWindow() + return ok(cmd, map[string]interface{}{ + "color": color, + "delay_ms": p.DelayMS, + "scheduled_unix_nano": scheduledUnix, + "qpc": qpcNow(), + "unix_nano": time.Now().UnixNano(), + }) + case "display_state": + return ok(cmd, currentDisplayState()) + case "hide": + stopDynamic() + showWindow(false) + return ok(cmd, map[string]interface{}{"hidden": true}) + case "begin_hid_capture": + clearEvents() + showWindow(true) + focusWindow() + return ok(cmd, map[string]interface{}{ + "qpc": qpcNow(), + "unix_nano": time.Now().UnixNano(), + }) + case "get_hid_events": + return ok(cmd, map[string]interface{}{"events": snapshotEvents()}) + case "msd_snapshot": + return ok(cmd, map[string]interface{}{"drives": listDrives()}) + case "msd_wait_new": + var p struct { + Known []string `json:"known"` + TimeoutMS int `json:"timeout_ms"` + } + _ = json.Unmarshal(cmd.Payload, &p) + if p.TimeoutMS <= 0 { + p.TimeoutMS = 30000 + } + drive, err := waitNewDrive(p.Known, time.Duration(p.TimeoutMS)*time.Millisecond) + if err != nil { + return fail(cmd, err) + } + return ok(cmd, drive) + case "msd_write_read": + var p struct { + Root string `json:"root"` + Filename string `json:"filename"` + SizeBytes int `json:"size_bytes"` + } + _ = json.Unmarshal(cmd.Payload, &p) + if p.SizeBytes <= 0 { + p.SizeBytes = 1024 * 1024 + } + if p.Filename == "" { + p.Filename = "okvm-msd-test.bin" + } + result, err := writeReadVerify(p.Root, p.Filename, p.SizeBytes) + if err != nil { + return fail(cmd, err) + } + return ok(cmd, result) + case "msd_wait_removed": + var p struct { + Root string `json:"root"` + TimeoutMS int `json:"timeout_ms"` + } + _ = json.Unmarshal(cmd.Payload, &p) + if p.TimeoutMS <= 0 { + p.TimeoutMS = 30000 + } + err := waitRemoved(p.Root, time.Duration(p.TimeoutMS)*time.Millisecond) + if err != nil { + return fail(cmd, err) + } + return ok(cmd, map[string]interface{}{"removed": true}) + default: + return fail(cmd, fmt.Errorf("unknown command: %s", cmd.Command)) + } +} + +func ok(cmd command, payload interface{}) response { + return response{ID: cmd.ID, OK: true, Type: cmd.Command, Payload: payload} +} + +func fail(cmd command, err error) response { + return response{ID: cmd.ID, OK: false, Type: cmd.Command, Error: err.Error()} +} + +func runUI(ready chan<- struct{}, hidden bool) { + runtime.LockOSThread() + defer runtime.UnlockOSThread() + + instance := getModuleHandle() + className := syscall.StringToUTF16Ptr("OKVMWinAgentWindow") + wndProc := syscall.NewCallback(windowProc) + wc := wndClassEx{ + Size: uint32(unsafe.Sizeof(wndClassEx{})), + WndProc: wndProc, + Instance: instance, + Background: colorWindow + 1, + ClassName: className, + } + procRegisterClassExW.Call(uintptr(unsafe.Pointer(&wc))) + + width, height := screenSize() + style := uintptr(wsPopup) + if !hidden { + style |= wsVisible + } + hwnd, _, err := procCreateWindowExW.Call( + wsExTopmost|wsExToolWindow, + uintptr(unsafe.Pointer(className)), + uintptr(unsafe.Pointer(syscall.StringToUTF16Ptr("One-KVM Test Agent"))), + style, + 0, + 0, + uintptr(width), + uintptr(height), + 0, + 0, + instance, + 0, + ) + if hwnd == 0 { + log.Fatalf("CreateWindowExW failed: %v", err) + } + state.mu.Lock() + state.hwnd = hwnd + state.mu.Unlock() + disableIME(hwnd) + if hidden { + showWindow(false) + } else { + showWindow(true) + } + close(ready) + + var m msg + for { + ret, _, _ := procGetMessageW.Call(uintptr(unsafe.Pointer(&m)), 0, 0, 0) + if int32(ret) <= 0 { + return + } + procTranslateMessage.Call(uintptr(unsafe.Pointer(&m))) + procDispatchMessageW.Call(uintptr(unsafe.Pointer(&m))) + } +} + +func windowProc(hwnd uintptr, message uintptr, wParam, lParam uintptr) uintptr { + msg := uint32(message) + switch msg { + case wmPaint: + var ps paintStruct + hdc, _, _ := procBeginPaint.Call(hwnd, uintptr(unsafe.Pointer(&ps))) + state.mu.Lock() + color := state.bgColor + state.mu.Unlock() + brush, _, _ := procCreateSolidBrush.Call(uintptr(color)) + r := rect{Left: 0, Top: 0, Right: int32(screenWidth()), Bottom: int32(screenHeight())} + procFillRect.Call(hdc, uintptr(unsafe.Pointer(&r)), brush) + procDeleteObject.Call(brush) + procEndPaint.Call(hwnd, uintptr(unsafe.Pointer(&ps))) + return 0 + case wmTimer: + if wParam == dynamicTimerID { + advanceDynamicFrame(hwnd) + return 0 + } + ret, _, _ := procDefWindowProcW.Call(hwnd, message, wParam, lParam) + return ret + case wmAppInvalidate: + invalidateWindowNow(hwnd) + return 0 + case wmAppShow: + procShowWindow.Call(hwnd, swShow) + invalidateWindowNow(hwnd) + return 0 + case wmAppHide: + procShowWindow.Call(hwnd, swHide) + return 0 + case wmAppFocus: + procSetForegroundWindow.Call(hwnd) + return 0 + case wmAppDynamicStart: + procKillTimer.Call(hwnd, dynamicTimerID) + interval := wParam + if interval == 0 { + interval = 16 + } + procSetTimer.Call(hwnd, dynamicTimerID, interval, 0) + invalidateWindowNow(hwnd) + return 0 + case wmAppDynamicStop: + procKillTimer.Call(hwnd, dynamicTimerID) + invalidateWindowNow(hwnd) + return 0 + case wmKeyDown: + appendEvent(hidEvent{Type: "key_down", Code: uint32(wParam), Modifiers: modifierState(), Qpc: qpcNow(), UnixNano: time.Now().UnixNano()}) + return 0 + case wmKeyUp: + appendEvent(hidEvent{Type: "key_up", Code: uint32(wParam), Modifiers: modifierState(), Qpc: qpcNow(), UnixNano: time.Now().UnixNano()}) + return 0 + case wmSysKeyDown: + appendEvent(hidEvent{Type: "key_down", Code: uint32(wParam), Modifiers: modifierState(), Description: "syskey", Qpc: qpcNow(), UnixNano: time.Now().UnixNano()}) + return 0 + case wmSysKeyUp: + appendEvent(hidEvent{Type: "key_up", Code: uint32(wParam), Modifiers: modifierState(), Description: "syskey", Qpc: qpcNow(), UnixNano: time.Now().UnixNano()}) + return 0 + case wmChar: + appendEvent(hidEvent{Type: "char", Code: uint32(wParam), Char: string(rune(wParam)), Modifiers: modifierState(), Qpc: qpcNow(), UnixNano: time.Now().UnixNano()}) + return 0 + case wmMouseMove: + x, y := mouseXY(lParam) + appendEvent(hidEvent{Type: "mouse_move", X: x, Y: y, Qpc: qpcNow(), UnixNano: time.Now().UnixNano()}) + return 0 + case wmLButtonDown, wmLButtonUp, wmRButtonDown, wmRButtonUp: + x, y := mouseXY(lParam) + appendEvent(hidEvent{Type: mouseMessageName(msg), X: x, Y: y, Qpc: qpcNow(), UnixNano: time.Now().UnixNano()}) + return 0 + case wmMouseWheel: + delta := int16((wParam >> 16) & 0xffff) + appendEvent(hidEvent{Type: "mouse_wheel", WheelDelta: delta, Qpc: qpcNow(), UnixNano: time.Now().UnixNano()}) + return 0 + case wmClose: + procShowWindow.Call(hwnd, swHide) + return 0 + case wmDestroy: + procKillTimer.Call(hwnd, dynamicTimerID) + procPostQuitMessage.Call(0) + return 0 + default: + ret, _, _ := procDefWindowProcW.Call(hwnd, message, wParam, lParam) + return ret + } +} + +func mouseMessageName(message uint32) string { + switch message { + case wmLButtonDown: + return "mouse_left_down" + case wmLButtonUp: + return "mouse_left_up" + case wmRButtonDown: + return "mouse_right_down" + case wmRButtonUp: + return "mouse_right_up" + default: + return "mouse_button" + } +} + +func mouseXY(lParam uintptr) (int32, int32) { + x := int16(lParam & 0xffff) + y := int16((lParam >> 16) & 0xffff) + return int32(x), int32(y) +} + +func modifierState() uint32 { + var mods uint32 + if isKeyDown(vkLControl) { + mods |= modLeftCtrl + } + if isKeyDown(vkLShift) { + mods |= modLeftShift + } + if isKeyDown(vkLMenu) { + mods |= modLeftAlt + } + if isKeyDown(vkLWin) { + mods |= modLeftMeta + } + if isKeyDown(vkRControl) { + mods |= modRightCtrl + } + if isKeyDown(vkRShift) { + mods |= modRightShift + } + if isKeyDown(vkRMenu) { + mods |= modRightAlt + } + if isKeyDown(vkRWin) { + mods |= modRightMeta + } + return mods +} + +func isKeyDown(vk uintptr) bool { + ret, _, _ := procGetKeyState.Call(vk) + return int16(ret&0xffff) < 0 +} + +func appendEvent(e hidEvent) { + state.mu.Lock() + defer state.mu.Unlock() + state.events = append(state.events, e) + if len(state.events) > 2048 { + state.events = append([]hidEvent(nil), state.events[len(state.events)-2048:]...) + } +} + +func clearEvents() { + state.mu.Lock() + state.events = nil + state.mu.Unlock() +} + +func snapshotEvents() []hidEvent { + state.mu.Lock() + defer state.mu.Unlock() + out := make([]hidEvent, len(state.events)) + copy(out, state.events) + return out +} + +func setColor(value string) map[string]interface{} { + stopDynamic() + return applyColor(value) +} + +func applyColor(value string) map[string]interface{} { + display := updateColorState(value) + postUIMessage(wmAppInvalidate, 0, 0) + return display +} + +func updateColorState(value string) map[string]interface{} { + colorHex := normalizeColor(value) + state.mu.Lock() + display := setColorStateLocked(colorHex) + state.mu.Unlock() + return display +} + +func startDynamic(fps int) map[string]interface{} { + if fps < 1 { + fps = 60 + } + if fps > 120 { + fps = 120 + } + stopDynamic() + state.mu.Lock() + state.dynamicActive = true + state.dynamicFPS = fps + state.dynamicFrame = 0 + display := setColorStateLocked(dynamicFrameColor(0)) + state.mu.Unlock() + postUIMessage(wmAppDynamicStart, uintptr(dynamicTimerIntervalMS(fps)), 0) + display["dynamic"] = true + display["fps"] = fps + return display +} + +func stopDynamic() { + state.mu.Lock() + active := state.dynamicActive + state.dynamicActive = false + state.dynamicFPS = 0 + state.dynamicFrame = 0 + state.mu.Unlock() + if active { + postUIMessage(wmAppDynamicStop, 0, 0) + } +} + +func dynamicTimerIntervalMS(fps int) int { + if fps < 1 { + fps = 60 + } + interval := 1000 / fps + if interval < 1 { + return 1 + } + return interval +} + +func dynamicFrameColor(frame int64) string { + r := uint8((frame*5 + 31) % 256) + g := uint8((frame*13 + 97) % 256) + b := uint8((frame*29 + 173) % 256) + return fmt.Sprintf("#%02x%02x%02x", r, g, b) +} + +func advanceDynamicFrame(hwnd uintptr) { + state.mu.Lock() + if !state.dynamicActive { + state.mu.Unlock() + return + } + state.dynamicFrame++ + setColorStateLocked(dynamicFrameColor(state.dynamicFrame)) + state.mu.Unlock() + invalidateWindowNow(hwnd) +} + +func setColorStateLocked(colorHex string) map[string]interface{} { + color := parseColor(colorHex) + qpc := qpcNow() + unixNano := time.Now().UnixNano() + state.bgColor = color + state.colorHex = colorHex + state.lastColorChangeQpc = qpc + state.lastColorChangeUnix = unixNano + state.colorSequence++ + seq := state.colorSequence + return map[string]interface{}{ + "color": colorHex, + "qpc": qpc, + "unix_nano": unixNano, + "last_change_qpc": qpc, + "last_change_unix_nano": unixNano, + "sequence": seq, + } +} + +func currentDisplayState() map[string]interface{} { + state.mu.Lock() + defer state.mu.Unlock() + return map[string]interface{}{ + "color": state.colorHex, + "last_change_qpc": state.lastColorChangeQpc, + "last_change_unix_nano": state.lastColorChangeUnix, + "sequence": state.colorSequence, + "dynamic": state.dynamicActive, + "dynamic_fps": state.dynamicFPS, + "qpc": qpcNow(), + "unix_nano": time.Now().UnixNano(), + } +} + +func showWindow(show bool) { + state.mu.Lock() + hwnd := state.hwnd + state.mu.Unlock() + if hwnd == 0 { + return + } + if show { + postUIMessage(wmAppShow, 0, 0) + } else { + postUIMessage(wmAppHide, 0, 0) + } +} + +func focusWindow() { + state.mu.Lock() + hwnd := state.hwnd + state.mu.Unlock() + if hwnd != 0 { + postUIMessage(wmAppFocus, 0, 0) + } +} + +func postUIMessage(message uint32, wParam uintptr, lParam uintptr) { + state.mu.Lock() + hwnd := state.hwnd + state.mu.Unlock() + if hwnd != 0 { + procPostMessageW.Call(hwnd, uintptr(message), wParam, lParam) + } +} + +func invalidateWindowNow(hwnd uintptr) { + procInvalidateRect.Call(hwnd, 0, 1) + procUpdateWindow.Call(hwnd) +} + +func disableIME(hwnd uintptr) { + if hwnd != 0 { + procImmAssociateContext.Call(hwnd, 0) + } +} + +func parseColor(value string) uint32 { + s := strings.TrimPrefix(strings.TrimSpace(value), "#") + if len(s) != 6 { + return 0x0000ff00 + } + var rgb uint64 + _, err := fmt.Sscanf(s, "%06x", &rgb) + if err != nil { + return 0x0000ff00 + } + r := rgb >> 16 & 0xff + g := rgb >> 8 & 0xff + b := rgb & 0xff + return uint32(r | (g << 8) | (b << 16)) +} + +func normalizeColor(value string) string { + s := strings.TrimPrefix(strings.TrimSpace(value), "#") + if len(s) != 6 { + return "#00ff00" + } + var rgb uint64 + if _, err := fmt.Sscanf(s, "%06x", &rgb); err != nil { + return "#00ff00" + } + return fmt.Sprintf("#%06x", rgb) +} + +func screenSize() (int, int) { + return screenWidth(), screenHeight() +} + +func screenInfo() map[string]int { + width, height := screenSize() + return map[string]int{"width": width, "height": height} +} + +func screenWidth() int { + r, _, _ := procGetSystemMetrics.Call(0) + return int(r) +} + +func screenHeight() int { + r, _, _ := procGetSystemMetrics.Call(1) + return int(r) +} + +func hostname() string { + h, err := os.Hostname() + if err != nil { + return "unknown" + } + return h +} + +func getModuleHandle() uintptr { + ret, _, _ := procGetModuleHandleW.Call(0) + return ret +} + +func qpcNow() int64 { + var value int64 + procQueryPerformanceCount.Call(uintptr(unsafe.Pointer(&value))) + return value +} + +func qpcFreq() int64 { + var value int64 + procQueryPerformanceFreq.Call(uintptr(unsafe.Pointer(&value))) + return value +} + +func listDrives() []driveInfo { + mask, _, _ := procGetLogicalDrives.Call() + now := time.Now().UnixNano() + drives := []driveInfo{} + for i := 0; i < 26; i++ { + if mask&(1< 1024*1024 { + chunk = 1024 * 1024 + } + chunk -= chunk % alignment + if chunk <= 0 { + return nil, fmt.Errorf("invalid uncached read chunk for alignment %d", alignment) + } + var read uint32 + ret, _, err := procReadFile.Call( + handle, + uintptr(unsafe.Pointer(&buf[total])), + uintptr(chunk), + uintptr(unsafe.Pointer(&read)), + 0, + ) + if ret == 0 { + return nil, winCallError("ReadFile FILE_FLAG_NO_BUFFERING", err) + } + if read == 0 { + break + } + total += int(read) + } + if total != sizeBytes { + return nil, fmt.Errorf("short uncached read: %d/%d", total, sizeBytes) + } + out := make([]byte, sizeBytes) + copy(out, buf) + return out, nil +} + +func winCallError(operation string, err error) error { + if errno, ok := err.(syscall.Errno); ok && errno == 0 { + return fmt.Errorf("%s failed", operation) + } + return fmt.Errorf("%s failed: %w", operation, err) +} diff --git a/test/okvm-test/okvm_media.py b/test/okvm-test/okvm_media.py new file mode 100644 index 00000000..4f2edec2 --- /dev/null +++ b/test/okvm-test/okvm_media.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +import io +from typing import Any + + +HDMI_COLOR_SEQUENCE: tuple[tuple[str, str], ...] = ( + ("black", "#000000"), + ("white", "#ffffff"), + ("gray16", "#101010"), + ("gray128", "#808080"), + ("gray235", "#ebebeb"), + ("red", "#ff0000"), + ("green", "#00ff00"), + ("blue", "#0000ff"), +) + + +def hex_to_rgb(value: str) -> tuple[int, int, int]: + s = value.strip().lstrip("#") + if len(s) != 6: + raise ValueError(f"invalid RGB color: {value}") + return int(s[0:2], 16), int(s[2:4], 16), int(s[4:6], 16) + + +def rgb_error(expected: tuple[int, int, int], measured: tuple[float, float, float]) -> dict[str, float]: + errors = [abs(float(measured[i]) - float(expected[i])) for i in range(3)] + return { + "mean_abs_error": sum(errors) / 3, + "max_abs_error": max(errors), + } + + +def closest_hdmi_color(measured: tuple[float, float, float]) -> tuple[str, float]: + best_name = "" + best_error = 999.0 + for name, color in HDMI_COLOR_SEQUENCE: + err = rgb_error(hex_to_rgb(color), measured)["mean_abs_error"] + if err < best_error: + best_name = name + best_error = err + return best_name, best_error + + +def jpeg_rgb_stats(frame: bytes) -> dict[str, Any]: + try: + from PIL import Image, ImageStat + except ImportError as exc: + raise RuntimeError("Pillow is required for HDMI color/latency tests; run pip install -r requirements.txt") from exc + + with Image.open(io.BytesIO(frame)) as image: + rgb = image.convert("RGB") + width, height = rgb.size + left = max(0, int(width * 0.35)) + top = max(0, int(height * 0.35)) + right = min(width, int(width * 0.65)) + bottom = min(height, int(height * 0.65)) + crop = rgb.crop((left, top, right, bottom)) + stat = ImageStat.Stat(crop) + return { + "width": width, + "height": height, + "sample_box": [left, top, right, bottom], + "mean_rgb": tuple(round(float(v), 2) for v in stat.mean), + "stddev_rgb": tuple(round(float(v), 2) for v in stat.stddev), + } + + +def percentile(values: list[float], pct: float) -> float: + if not values: + return 0.0 + ordered = sorted(values) + if len(ordered) == 1: + return ordered[0] + rank = (len(ordered) - 1) * (pct / 100) + lower = int(rank) + upper = min(lower + 1, len(ordered) - 1) + weight = rank - lower + return ordered[lower] * (1 - weight) + ordered[upper] * weight diff --git a/test/okvm-test/okvm_report.py b/test/okvm-test/okvm_report.py new file mode 100644 index 00000000..f2c0b98c --- /dev/null +++ b/test/okvm-test/okvm_report.py @@ -0,0 +1,671 @@ +from __future__ import annotations + +import os +import sys +import time +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + + +@dataclass +class CheckResult: + name: str + status: str + message: str = "" + data: dict[str, Any] = field(default_factory=dict) + + +@dataclass +class Metric: + name: str + value: Any + unit: str = "" + labels: dict[str, Any] = field(default_factory=dict) + + +@dataclass +class Evidence: + category: str + title: str + path: str + + +STATUS_TEXT = { + "PASS": "通过", + "FAIL": "失败", + "WARN": "警告", + "SKIP": "跳过", +} + +STATUS_COLOR = { + "PASS": "\033[32m", + "FAIL": "\033[31m", + "WARN": "\033[33m", + "SKIP": "\033[33m", + "INFO": "\033[36m", +} + +CATEGORIES_WITH_DATA_TABLE = { + "初始化与环境", + "视频性能", + "HDMI 画面与颜色", + "HID / MSD / ATX", +} + +CATEGORIES_WITHOUT_DATA_TABLE: set[str] = set() + +REPORT_HIDDEN_RESULTS = { + "log_collection", + "screenshot", +} + +CATEGORY_RULES = ( + ("初始化与环境", ("target_", "setup_", "login", "network_", "target_inventory", "stream_codecs", "windows_agent", "ventoy_resources", "msd_restart")), + ("视频性能", ("video_", "config_video_")), + ("HDMI 画面与颜色", ("hdmi_", "config_video_hdmi_probe")), + ("HID / MSD / ATX", ("hid_", "msd", "atx_")), +) + +DISPLAY_NAMES = { + "target_reset": "目标机重置", + "setup_init": "初始化账号", + "login": "登录", + "login_after_restart": "重启后登录", + "network_latency": "网络延迟", + "target_inventory": "目标机设备清单", + "hid_msd_config": "HID/MSD 配置", + "ventoy_resources": "Ventoy 资源", + "msd_restart": "MSD 启用后重启", + "video_input_select": "视频输入选择", + "windows_agent": "Windows 配套程序连接", + "config_video_hdmi_probe": "HDMI 采集配置", + "hdmi_identity": "HDMI 画面来源确认", + "hdmi_color_range": "HDMI 颜色偏差", + "hdmi_latency_mjpeg": "MJPEG 画面延迟", + "hid_status": "HID 状态", + "hid_input": "HID 输入", + "hid_latency": "HID 延迟", + "msd": "MSD 虚拟盘", + "atx_api": "ATX API", + "log_collection": "日志采集", + "screenshot": "网页截图", +} + + +class Reporter: + def __init__(self, report_dir: Path, color: bool | None = None): + self.report_dir = report_dir + self.results: list[CheckResult] = [] + self.metrics: list[Metric] = [] + self.evidence: list[Evidence] = [] + self.report_dir.mkdir(parents=True, exist_ok=True) + (self.report_dir / "evidence").mkdir(exist_ok=True) + self.use_color = color if color is not None else (sys.stdout.isatty() and not os.environ.get("NO_COLOR")) + + def add(self, check_name: str, outcome: str, detail: str = "", **data: Any) -> None: + self.results.append(CheckResult(name=check_name, status=outcome, message=detail, data=data)) + prefix = {"PASS": "[PASS]", "FAIL": "[FAIL]", "SKIP": "[SKIP]", "WARN": "[WARN]"}.get(outcome, "[INFO]") + print(f"{self.colorize(prefix, outcome)} {check_name}: {detail}") + + def info(self, check_name: str, detail: str) -> None: + print(f"{self.colorize('[INFO]', 'INFO')} {check_name}: {detail}") + + def metric(self, name: str, value: Any, unit: str = "", **labels: Any) -> None: + self.metrics.append(Metric(name=name, value=value, unit=unit, labels=labels)) + + def add_evidence(self, category: str, title: str, path: Path) -> None: + rel = path.relative_to(self.report_dir) if path.is_relative_to(self.report_dir) else path + self.evidence.append(Evidence(category=category, title=title, path=str(rel))) + + def write(self, run_id: str, target: str) -> None: + generated_at = time.strftime("%Y-%m-%d %H:%M:%S %z", time.localtime()) + report_timestamp = time.strftime("%Y%m%d-%H%M%S", time.localtime()) + status = "失败" if any(r.status == "FAIL" for r in self.report_results()) else "通过" + md_path = self.report_dir / f"report-{report_timestamp}.md" + user_md_path = self.report_dir / f"user-report-{report_timestamp}.md" + md_path.write_text(self.render_markdown(run_id, target, generated_at, status), encoding="utf-8") + user_md_path.write_text(self.render_user_markdown(run_id), encoding="utf-8") + self.info("markdown_report", str(md_path)) + self.info("user_markdown_report", str(user_md_path)) + + def colorize(self, text: str, status: str) -> str: + if not self.use_color: + return text + return f"{STATUS_COLOR.get(status, '')}{text}\033[0m" + + def render_markdown(self, run_id: str, target: str, generated_at: str, status: str) -> str: + counts = self.status_counts() + lines = [ + "# One-KVM 自动化测试报告", + "", + f"- 运行编号:`{run_id}`", + f"- 目标设备:`{target}`", + f"- 生成时间:`{generated_at}`", + f"- 总体结果:**{status}**", + f"- 统计:通过 {counts.get('PASS', 0)},警告 {counts.get('WARN', 0)},跳过 {counts.get('SKIP', 0)},失败 {counts.get('FAIL', 0)}", + "", + ] + + grouped = self.group_results() + for category, results in grouped.items(): + if not results: + continue + lines.extend([f"## {category}", ""]) + lines.extend(self.render_result_table(category, results)) + lines.append("") + + return "\n".join(lines).rstrip() + "\n" + + def render_user_markdown(self, run_id: str) -> str: + lines = [ + "# One-KVM 测试摘要", + "", + "## 背景", + "", + f"- 运行编号:`{run_id}`", + "- 测试设备:", + f"- 视频设备:{markdown_inline(self.user_video_device())}", + f"- HID设备:{markdown_inline(self.user_hid_backend())}", + f"- 网络延迟:{markdown_inline(self.user_network_latency())}", + "", + "## 视频性能", + "", + "| 视频输入参数 | 测试帧率 | 延迟统计(中位数 p50 / 95%分位 p95 / 最大值 max) |", + "| --- | --- | --- |", + ] + video_rows = self.user_video_rows() + if video_rows: + for params, fps, latency in video_rows: + lines.append( + "| " + + " | ".join( + ( + markdown_table_cell(params), + markdown_table_cell(fps), + markdown_table_cell(latency), + ) + ) + + " |" + ) + else: + lines.append("| 无数据 | - | - |") + + lines.extend( + [ + "", + "## HID性能", + "", + "| 输入方式 | 延迟统计(中位数 p50 / 95%分位 p95 / 最大值 max) |", + "| --- | --- |", + ] + ) + hid_rows = self.user_hid_rows() + if hid_rows: + for method, latency in hid_rows: + lines.append(f"| {markdown_table_cell(method)} | {markdown_table_cell(latency)} |") + else: + lines.append("| 无数据 | - |") + + lines.extend( + [ + "", + "## MSD性能", + "", + "| 操作 | 数据 |", + "| --- | --- |", + ] + ) + msd_rows = self.user_msd_rows() + if msd_rows: + for operation, data in msd_rows: + lines.append(f"| {markdown_table_cell(operation)} | {markdown_table_cell(data)} |") + else: + lines.append("| 无数据 | - |") + + return "\n".join(lines).rstrip() + "\n" + + def find_result(self, name: str) -> CheckResult | None: + for result in self.results: + if result.name == name: + return result + return None + + def user_network_latency(self) -> str: + result = self.find_result("network_latency") + if not result: + return "无数据" + tcp = result.data.get("tcp_connect") or {} + if tcp.get("samples"): + return format_latency_values(tcp) + return result.message or "无数据" + + def user_video_device(self) -> str: + result = self.find_result("video_input_select") + cases = result.data.get("cases") if result else None + if not cases: + return "无数据" + by_device: dict[str, list[str]] = {} + for case in cases: + if not isinstance(case, dict): + continue + device = str(case.get("device") or "未知设备") + by_device.setdefault(device, []).append(format_video_case(case)) + return ";".join(f"{device}:{', '.join(items)}" for device, items in by_device.items()) or "无数据" + + def user_hid_backend(self) -> str: + config = self.find_result("hid_msd_config") + if config: + if config.data.get("udc"): + return "OTG" + if config.data.get("port"): + return "CH9329" + status = self.find_result("hid_status") + if status: + data = status.data.get("status") or {} + backend = str(data.get("backend") or data.get("hid_backend") or "").strip() + if backend: + return backend.upper() if backend.lower() == "otg" else backend + return "无数据" + + def user_video_rows(self) -> list[tuple[str, str, str]]: + fps_by_key: dict[tuple[str, str], str] = {} + for result in self.results: + if not result.name.startswith("video_") or result.name.startswith("video_latency_"): + continue + data = result.data + case = data.get("case") or {} + if not isinstance(case, dict): + continue + output_mode = str(data.get("codec") or result.name.rsplit("_", 1)[-1]).lower() + label = str(case.get("label") or "") + fps = data.get("fps") + if fps is None: + fps = data.get("avgFps") + if label and output_mode and fps is not None: + fps_by_key[(label, output_mode)] = format_fps(float(fps)) + + rows: list[tuple[str, str, str]] = [] + for result in self.results: + if not result.name.startswith("video_latency_"): + continue + data = result.data + case = data.get("video_case") or {} + if not isinstance(case, dict): + case = {} + output_mode = str(data.get("output_mode") or video_latency_output_from_name(result.name) or "").lower() + label = str(case.get("label") or "") + params = format_video_latency_params(case, output_mode) + fps = fps_by_key.get((label, output_mode), "-") + latency = format_latency_data(data) + if not latency: + latency = f"{STATUS_TEXT.get(result.status, result.status)}:{result.message}" if result.message else STATUS_TEXT.get(result.status, result.status) + rows.append((params, fps, latency)) + return rows + + def user_hid_rows(self) -> list[tuple[str, str]]: + result = self.find_result("hid_latency") + if not result: + return [] + latency = format_latency_data(result.data) + if not latency: + latency = f"{STATUS_TEXT.get(result.status, result.status)}:{result.message}" if result.message else STATUS_TEXT.get(result.status, result.status) + return [(self.user_hid_backend(), latency)] + + def user_msd_rows(self) -> list[tuple[str, str]]: + result = self.find_result("msd") + if not result: + return [] + verify = result.data.get("verify") or {} + rows: list[tuple[str, str]] = [] + if "write_mib_s" in verify: + rows.append(("写", f"{float(verify.get('write_mib_s') or 0):.2f}MiB/s")) + if "read_mib_s" in verify: + rows.append(("读", f"{float(verify.get('read_mib_s') or 0):.2f}MiB/s")) + elif "cached_read_mib_s" in verify: + rows.append(("读(缓存,仅校验)", f"{float(verify.get('cached_read_mib_s') or 0):.2f}MiB/s")) + if rows: + return rows + return [("MSD", f"{STATUS_TEXT.get(result.status, result.status)}:{result.message}" if result.message else STATUS_TEXT.get(result.status, result.status))] + + def report_results(self) -> list[CheckResult]: + return [result for result in self.results if result.name not in REPORT_HIDDEN_RESULTS] + + def status_counts(self) -> dict[str, int]: + counts: dict[str, int] = {} + for result in self.report_results(): + counts[result.status] = counts.get(result.status, 0) + 1 + return counts + + def group_results(self) -> dict[str, list[CheckResult]]: + grouped = {name: [] for name, _ in CATEGORY_RULES} + grouped["其他"] = [] + for result in self.report_results(): + for category, prefixes in CATEGORY_RULES: + if any(result.name == prefix or result.name.startswith(prefix) for prefix in prefixes): + grouped[category].append(result) + break + else: + grouped["其他"].append(result) + return grouped + + def render_result(self, result: CheckResult) -> list[str]: + title = display_name(result.name) + status = STATUS_TEXT.get(result.status, result.status) + lines = [f"### {title}", "", f"- 结果:**{status}**"] + summary = summarize_result(result, self.metrics) + if summary: + lines.append(f"- 数据:{summary}") + if result.status == "FAIL": + lines.append(f"- 失败日志:`{markdown_inline(result.message)}`") + elif result.status in {"WARN", "SKIP"} and result.message: + lines.append(f"- 说明:{markdown_inline(result.message)}") + return lines + + def render_result_table(self, category: str, results: list[CheckResult]) -> list[str]: + with_data = category in CATEGORIES_WITH_DATA_TABLE or ( + category not in CATEGORIES_WITHOUT_DATA_TABLE + and any(has_numeric_data(result, self.metrics) for result in results) + ) + if with_data: + lines = ["| 项目 | 结果 | 数据 |", "| --- | --- | --- |"] + for result in results: + lines.append( + "| " + + " | ".join( + ( + markdown_table_cell(display_name(result.name)), + markdown_table_cell(STATUS_TEXT.get(result.status, result.status)), + markdown_table_cell(summarize_result(result, self.metrics)), + ) + ) + + " |" + ) + else: + lines = ["| 项目 | 结果 |", "| --- | --- |"] + for result in results: + lines.append( + "| " + + " | ".join( + ( + markdown_table_cell(display_name(result.name)), + markdown_table_cell(STATUS_TEXT.get(result.status, result.status)), + ) + ) + + " |" + ) + + sections = ( + ("失败项目", [result for result in results if result.status == "FAIL"]), + ("跳过项目", [result for result in results if result.status == "SKIP"]), + ("警告项目", [result for result in results if result.status == "WARN"]), + ) + for heading, items in sections: + if not items: + continue + lines.extend(["", f"{heading}:"]) + for result in items: + title = display_name(result.name) + status = STATUS_TEXT.get(result.status, result.status) + reason = result.message or summarize_result(result, self.metrics) or "无详细日志" + lines.append(f"- **{markdown_inline(title)}**({status}):{markdown_inline(reason)}") + return lines + + +def summarize_result(result: CheckResult, metrics: list[Metric]) -> str: + data = result.data + name = result.name + if name == "network_latency": + tcp = data.get("tcp_connect") or {} + http = data.get("http_health") or {} + parts = [] + if tcp.get("samples"): + parts.append( + f"TCP p50={float(tcp.get('p50_ms', 0)):.1f}ms,p95={float(tcp.get('p95_ms', 0)):.1f}ms,max={float(tcp.get('max_ms', 0)):.1f}ms" + ) + if http.get("samples"): + parts.append( + f"HTTP p50={float(http.get('p50_ms', 0)):.1f}ms,p95={float(http.get('p95_ms', 0)):.1f}ms,max={float(http.get('max_ms', 0)):.1f}ms" + ) + errors = data.get("errors") or [] + if errors: + parts.append(f"异常样本={len(errors)}") + return ";".join(parts) + if name.startswith("config_video_") and isinstance(data.get("case"), dict): + case = data["case"] + return f"{case.get('device')} {case.get('fmt')} {case.get('width')}x{case.get('height')}@{case.get('fps')}" + if name.startswith("video_latency_") or name == "hdmi_latency_mjpeg": + if not any(k in data for k in ("p50_ms", "p95_ms", "max_ms")): + return "" + case = data.get("video_case") or {} + output_mode = str(data.get("output_mode") or video_latency_output_from_name(name) or "").upper() + parts = [] + if output_mode: + parts.append(output_mode) + if isinstance(case, dict) and case.get("fmt"): + parts.append(f"{case.get('fmt')} {case.get('width')}x{case.get('height')}@{case.get('fps')}") + parts.extend( + [ + f"p50={float(data.get('p50_ms', 0)):.1f}ms", + f"p95={float(data.get('p95_ms', 0)):.1f}ms", + f"max={float(data.get('max_ms', 0)):.1f}ms", + ] + ) + return ",".join(parts) + if name.startswith("video_"): + parts = [] + if "fps" in data: + parts.append(f"fps={float(data['fps']):.1f}") + if "avgFps" in data: + parts.append(f"平均fps={float(data['avgFps']):.1f}") + if "input_requested_fps" in data: + parts.append(f"请求输入={float(data['input_requested_fps']):.1f}fps") + if "maxRtt" in data: + parts.append(f"最大RTT={float(data['maxRtt']) * 1000:.1f}ms") + if "min_expected_fps" in data: + parts.append(f"目标下限={float(data['min_expected_fps']):.1f}fps") + if "dynamic_source_fps" in data: + parts.append(f"动态源={int(data['dynamic_source_fps'])}fps") + if data.get("fps_above_requested"): + parts.append("输出帧率高于请求输入") + if data.get("unsupported"): + parts.append("不支持") + return ",".join(parts) + if name == "hdmi_identity": + samples = data.get("samples") or [] + ok = sum(1 for item in samples if item.get("identity_match")) + parts = [f"颜色匹配 {ok}/{len(samples)}"] + if samples: + detail = [] + for item in samples: + rgb = item.get("measured_rgb_mean") + status = "匹配" if item.get("identity_match") else "不匹配" + detail.append(f"{item.get('name')}={rgb}({status})") + parts.append(";".join(detail)) + return ",".join(parts) + if name == "hdmi_color_range": + samples = data.get("samples") or [] + if not samples: + return result.message + worst = max(samples, key=lambda item: float(item.get("mean_abs_error") or 0)) + missed = sum(1 for item in samples if not item.get("target_detected")) + max_mean_error = max((float(item.get("mean_abs_error") or 0) for item in samples), default=0.0) + max_abs_error = max((float(item.get("max_abs_error") or 0) for item in samples), default=0.0) + return ( + f"最大平均误差={max_mean_error:.1f},最大通道误差={max_abs_error:.1f}," + f"未命中颜色={missed}/{len(samples)}," + f"最差={worst.get('name')} 实测RGB={worst.get('measured_rgb_mean')}," + f"采样帧={worst.get('frames_seen', 0)}" + ) + if name == "hid_input": + count = int(data.get("event_count") or len(data.get("events") or [])) + keyboard = data.get("keyboard") or {} + mouse = data.get("mouse") or {} + parts = [ + f"捕获事件数={count}", + f"字母数字={keyboard.get('alphanumeric_tested', 0)}", + f"功能键={keyboard.get('function_keys_tested', 0)}", + f"安全组合键={keyboard.get('safe_combos_tested', 0)}", + f"绝对鼠标={'通过' if mouse.get('absolute_ok') else '失败'}", + f"相对鼠标={'通过' if mouse.get('relative_ok') else '失败'}", + ] + missing = [] + for key in ( + "missing_alphanumeric_down", + "missing_alphanumeric_up", + "missing_function_down", + "missing_function_up", + "missing_combos", + ): + values = keyboard.get(key) or [] + if values: + missing.append(f"{key}={','.join(map(str, values[:10]))}") + if missing: + parts.append("缺失:" + ";".join(missing)) + return ",".join(parts) + if name == "hid_latency": + if not any(k in data for k in ("p50_ms", "p95_ms", "max_ms")): + return "" + parts = [ + f"按键={data.get('key', '')}", + f"p50={float(data.get('p50_ms', 0)):.1f}ms", + f"p95={float(data.get('p95_ms', 0)):.1f}ms", + f"max={float(data.get('max_ms', 0)):.1f}ms", + ] + missing = int(data.get("missing_trials") or 0) + if missing: + parts.append(f"未捕获={missing}") + return ",".join(parts) + if name == "msd": + verify = data.get("verify") or {} + size = verify.get("size_bytes") + if not size: + return "" + parts = [f"读写校验大小={size} bytes"] + if "write_mib_s" in verify: + parts.append(f"写入={float(verify.get('write_mib_s') or 0):.2f}MiB/s") + if "read_mib_s" in verify: + parts.append(f"未缓存读取={float(verify.get('read_mib_s') or 0):.2f}MiB/s") + elif "cached_read_mib_s" in verify: + parts.append(f"缓存读取={float(verify.get('cached_read_mib_s') or 0):.2f}MiB/s(仅校验)") + if "write_ms" in verify and "read_ms" in verify: + parts.append(f"写耗时={float(verify.get('write_ms') or 0):.1f}ms,读耗时={float(verify.get('read_ms') or 0):.1f}ms") + if verify.get("read_cached"): + parts.append("读速未作为真实盘读速") + return ",".join(parts) + if name == "atx_api": + return "status/config/WOL API 均有响应" + if name == "windows_agent": + hello = data.get("hello") or {} + return f"主机={hello.get('hostname', 'unknown')}" + if name == "target_inventory": + return "已采集 lsusb、uname、服务状态" + if name == "target_reset": + return "数据库已备份,服务已重启" + if name == "setup_init": + return "初始化检查完成" + if name in {"login", "login_after_restart"}: + return "认证成功" + if name == "hid_msd_config": + return "已按设备能力配置 HID/MSD" + if name == "ventoy_resources": + return "Ventoy 资源检查完成" + if name == "msd_restart": + return "服务重启完成" + if name == "video_input_select": + cases = data.get("cases") or [] + return ",".join(f"{c.get('fmt')} {c.get('width')}x{c.get('height')}@{c.get('fps')}" for c in cases if isinstance(c, dict)) + return "" + + +def display_name(name: str) -> str: + if name.startswith("video_latency_"): + body = name.removeprefix("video_latency_") + for mode in ("mjpeg", "h264", "h265"): + suffix = f"_{mode}" + if body == mode: + return f"视频延迟 {mode.upper()}" + if body.endswith(suffix): + label = body[: -len(suffix)] + return f"视频延迟 {label} {mode.upper()}" + return f"视频延迟 {body}" + return DISPLAY_NAMES.get(name, name) + + +def format_video_case(case: dict[str, Any]) -> str: + fmt = str(case.get("fmt") or "").lower() + resolution = format_resolution(case) + fps = format_fps_value(case.get("fps")) + return " ".join(part for part in (f"{resolution}@{fps}" if resolution and fps else resolution or fps, fmt) if part) + + +def format_video_latency_params(case: dict[str, Any], output_mode: str) -> str: + fmt = str(case.get("fmt") or "").lower() + output = output_mode.lower() if output_mode else "unknown" + resolution = format_resolution(case) + fps = format_fps_value(case.get("fps")) + input_part = " ".join(part for part in (f"{resolution}@{fps}" if resolution and fps else resolution or fps, fmt) if part) + return f"{input_part}-->{output}" if input_part else output + + +def format_resolution(case: dict[str, Any]) -> str: + try: + width = int(case.get("width") or 0) + height = int(case.get("height") or 0) + except Exception: + return "" + if height > 0 and width in {1280, 1920, 2560, 3840, 4096}: + return f"{height}p" + if width > 0 and height > 0: + return f"{width}x{height}" + return "" + + +def format_fps(value: float) -> str: + return f"{value:.0f}fps" if abs(value - round(value)) < 0.05 else f"{value:.1f}fps" + + +def format_fps_value(value: Any) -> str: + try: + return format_fps(float(value)) + except Exception: + return "" + + +def format_latency_data(data: dict[str, Any]) -> str: + if not any(key in data for key in ("p50_ms", "p95_ms", "max_ms")): + return "" + return format_latency_values(data) + + +def format_latency_values(data: dict[str, Any]) -> str: + return ( + f"p50={float(data.get('p50_ms', 0)):.1f}ms," + f"p95={float(data.get('p95_ms', 0)):.1f}ms," + f"max={float(data.get('max_ms', 0)):.1f}ms" + ) + + +def video_latency_output_from_name(name: str) -> str: + body = name.removeprefix("video_latency_") if name.startswith("video_latency_") else name + for mode in ("mjpeg", "h264", "h265"): + if body == mode or body.endswith(f"_{mode}"): + return mode + return "" + + +def has_numeric_data(result: CheckResult, metrics: list[Metric]) -> bool: + summary = summarize_result(result, metrics) + return any(ch.isdigit() for ch in summary) + + +def markdown_inline(value: str) -> str: + return str(value).replace("\n", " | ").replace("`", "'") + + +def markdown_table_cell(value: str) -> str: + return markdown_inline(value).replace("|", "\\|") + + +def markdown_link(path: str) -> str: + return path.replace(" ", "%20") diff --git a/test/okvm-test/okvm_testctl.py b/test/okvm-test/okvm_testctl.py new file mode 100755 index 00000000..bb7761b8 --- /dev/null +++ b/test/okvm-test/okvm_testctl.py @@ -0,0 +1,2319 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import asyncio +import getpass +import inspect +import json +import lzma +import os +import re +import socket +import struct +import sys +import time +import uuid +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from okvm_media import HDMI_COLOR_SEQUENCE, closest_hdmi_color, hex_to_rgb, jpeg_rgb_stats, percentile, rgb_error +from okvm_report import Reporter + +try: + import httpx +except ImportError: # Keep --help usable before dependencies are installed. + httpx = None # type: ignore[assignment] + + +HID_KEY_USAGE: dict[str, tuple[int, int]] = { + **{chr(ord("a") + i): (0x04 + i, 0x00) for i in range(26)}, + **{chr(ord("A") + i): (0x04 + i, 0x02) for i in range(26)}, + "1": (0x1E, 0x00), + "2": (0x1F, 0x00), + "3": (0x20, 0x00), + "4": (0x21, 0x00), + "5": (0x22, 0x00), + "6": (0x23, 0x00), + "7": (0x24, 0x00), + "8": (0x25, 0x00), + "9": (0x26, 0x00), + "0": (0x27, 0x00), + "-": (0x2D, 0x00), + "_": (0x2D, 0x02), + " ": (0x2C, 0x00), + "\n": (0x28, 0x00), +} + +HID_ALNUM_KEYS: list[tuple[str, int, int]] = [ + *[(chr(ord("A") + i), 0x04 + i, 0x41 + i) for i in range(26)], + *[(str((i + 1) % 10), 0x1E + i, 0x31 + i if i < 9 else 0x30) for i in range(10)], +] +HID_ALNUM_TEXT = "abcdefghijklmnopqrstuvwxyz1234567890" +HID_FUNCTION_KEYS: list[tuple[str, int, int]] = [ + (f"F{i}", 0x39 + i, 0x6F + i) for i in range(1, 13) +] + +MOD_LEFT_CTRL = 0x01 +MOD_LEFT_SHIFT = 0x02 + +HID_SAFE_COMBOS: list[tuple[str, int, int, int]] = [ + ("Ctrl+F9", 0x42, 0x78, MOD_LEFT_CTRL), + ("Shift+F11", 0x44, 0x7A, MOD_LEFT_SHIFT), + ("Ctrl+Shift+F12", 0x45, 0x7B, MOD_LEFT_CTRL | MOD_LEFT_SHIFT), +] +HID_LATENCY_KEY = ("F8", 0x41, 0x77) +VIDEO_LATENCY_OUTPUT_MODES = ("mjpeg", "h264", "h265") +CHROME_BROWSER_CHANNEL = "chrome" +CHROME_BROWSER_NAME = "Chrome" +CHROME_INSTALL_COMMAND = "python -m playwright install chrome" +CHROMIUM_WINDOWS_ARGS = [ + "--enable-features=WebRtcAllowH265Receive", + "--force-fieldtrials=WebRTC-Video-H26xPacketBuffer/Enabled", + "--enable-gpu", + "--ignore-gpu-blocklist", + "--use-angle=d3d11", +] + +@dataclass +class VideoInputCase: + label: str + input_class: str + device: str + fmt: str + width: int + height: int + fps: float + +class SSHRunner: + def __init__(self, host: str, user: str, password: str | None, port: int = 22): + self.host = host + self.user = user + self.password = password + self.port = port + + def connect(self) -> Any: + try: + import paramiko + except ImportError as exc: + raise RuntimeError("paramiko is required for SSH; run pip install -r requirements.txt") from exc + + client = paramiko.SSHClient() + client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + client.connect( + self.host, + port=self.port, + username=self.user, + password=self.password, + look_for_keys=self.password is None, + allow_agent=self.password is None, + timeout=15, + ) + return client + + def run(self, command: str, timeout: int = 60) -> tuple[int, str, str]: + client = self.connect() + try: + _, stdout, stderr = client.exec_command(command, timeout=timeout) + out = stdout.read().decode("utf-8", errors="replace") + err = stderr.read().decode("utf-8", errors="replace") + code = stdout.channel.recv_exit_status() + return code, out, err + finally: + client.close() + + +class ApiClient: + def __init__(self, target: str, port: int, timeout: float = 15.0): + if httpx is None: + raise RuntimeError("httpx is required; run pip install -r requirements.txt") + self.base = f"http://{target}:{port}" + self.client = httpx.Client(base_url=self.base, timeout=timeout, follow_redirects=True) + + def close(self) -> None: + self.client.close() + + def cookie_header(self) -> str: + return "; ".join(f"{cookie.name}={cookie.value}" for cookie in self.client.cookies.jar) + + def request(self, method: str, path: str, **kwargs: Any) -> Any: + response = self.client.request(method, f"/api{path}", **kwargs) + content_type = response.headers.get("content-type", "") + data: Any + if "json" in content_type: + data = response.json() + else: + try: + data = response.json() + except Exception: + data = response.text + if response.status_code >= 400: + raise RuntimeError(f"{method} {path} -> HTTP {response.status_code}: {data}") + if isinstance(data, dict) and data.get("success") is False: + raise RuntimeError(f"{method} {path} failed: {data}") + return data + + def get(self, path: str) -> Any: + return self.request("GET", path) + + def post(self, path: str, payload: dict[str, Any] | None = None) -> Any: + return self.request("POST", path, json=payload or {}) + + def patch(self, path: str, payload: dict[str, Any]) -> Any: + return self.request("PATCH", path, json=payload) + + def wait_health(self, timeout: int = 60) -> dict[str, Any]: + deadline = time.monotonic() + timeout + last_error: Exception | None = None + while time.monotonic() < deadline: + try: + return self.get("/health") + except Exception as exc: + last_error = exc + time.sleep(1) + raise TimeoutError(f"One-KVM health check did not pass in {timeout}s: {last_error}") + + +class AgentClient: + def __init__(self, host: str, port: int, reporter: Reporter): + self.host = host + self.port = port + self.reporter = reporter + self.websocket: Any = None + self.hello: dict[str, Any] | None = None + self.connected = asyncio.Event() + self.lock = asyncio.Lock() + + async def stop(self) -> None: + if self.websocket: + await self.websocket.close() + self.websocket = None + self.connected.clear() + + async def wait_connected(self, timeout: int = 180) -> bool: + deadline = time.monotonic() + timeout + last_error: Exception | None = None + while time.monotonic() < deadline: + try: + await self._connect_once() + return True + except Exception as exc: + last_error = exc + await asyncio.sleep(1) + self.reporter.add("windows_agent", "FAIL", f"failed to connect Windows agent: {last_error}") + return False + + async def _connect_once(self) -> None: + if self.websocket and self.connected.is_set(): + return + try: + import websockets + except ImportError as exc: + raise RuntimeError("websockets is required for agent support; run pip install -r requirements.txt") from exc + + uri = f"ws://{self.host}:{self.port}/agent" + self.websocket = await websockets.connect(uri, max_size=8 * 1024 * 1024) + raw = await asyncio.wait_for(self.websocket.recv(), 10) + data = json.loads(raw) + if data.get("type") != "hello": + raise RuntimeError(f"unexpected agent hello: {data}") + self.hello = data + self.connected.set() + self.reporter.add("windows_agent", "PASS", "connected to Windows agent", hello=data) + + async def command(self, name: str, payload: dict[str, Any] | None = None, timeout: int = 30) -> dict[str, Any]: + if not self.websocket or not self.connected.is_set(): + raise RuntimeError("Windows agent is not connected") + msg_id = str(uuid.uuid4()) + async with self.lock: + await self.websocket.send(json.dumps({"id": msg_id, "command": name, "payload": payload or {}})) + response = json.loads(await asyncio.wait_for(self.websocket.recv(), timeout)) + if not response.get("ok"): + raise RuntimeError(response.get("error") or f"agent command failed: {name}") + return response.get("payload") or {} + + +class DeviceSelector: + CSI_HINTS = ("rkcif", "rk_hdmirx", "mipi", "csi", "platform", "hdmirx") + + def __init__(self, lsusb_tree: str, devices: dict[str, Any]): + self.lsusb_tree = lsusb_tree + self.devices = devices + + def classify(self, device: dict[str, Any]) -> str: + haystack = " ".join( + str(device.get(k, "")) for k in ("name", "driver", "path") + ).lower() + if any(h in haystack for h in self.CSI_HINTS) or not device.get("usb_bus"): + return "csi_mipi" + if re.search(r"\b(5000|10000|20000)M\b", self.lsusb_tree): + return "usb3" + return "usb2" + + @staticmethod + def _find_format(device: dict[str, Any], fmt: str) -> dict[str, Any] | None: + want = fmt.upper() + for item in device.get("formats", []): + got = str(item.get("format", "")).upper() + if want in got or got in want: + return item + return None + + @staticmethod + def _pick_exact(fmt: dict[str, Any], width: int, height: int, fps: float) -> tuple[int, int, float] | None: + for res in fmt.get("resolutions", []): + if int(res.get("width", 0)) == width and int(res.get("height", 0)) == height: + fps_values = [float(x) for x in res.get("fps", [])] + if not fps_values: + continue + best = min(fps_values, key=lambda x: abs(x - fps)) + if best >= fps * 0.9: + return width, height, best + return None + + @staticmethod + def _pick_highest_1080(fmt: dict[str, Any]) -> tuple[int, int, float] | None: + candidates: list[tuple[int, int, float]] = [] + for res in fmt.get("resolutions", []): + width = int(res.get("width", 0)) + height = int(res.get("height", 0)) + if width > 1920 or height > 1080: + continue + for fps in res.get("fps", []): + candidates.append((width, height, float(fps))) + if not candidates: + return None + return max(candidates, key=lambda x: (x[0] * x[1], x[2])) + + def select(self) -> list[VideoInputCase]: + video_devices = self.devices.get("video", []) + if not video_devices: + return [] + ordered = sorted(video_devices, key=lambda d: (not bool(d.get("has_signal")), d.get("path", ""))) + device = ordered[0] + input_class = self.classify(device) + path = str(device["path"]) + cases: list[VideoInputCase] = [] + + if input_class == "csi_mipi": + nv12 = self._find_format(device, "NV12") + if nv12: + picked = self._pick_exact(nv12, 1920, 1080, 60) or self._pick_highest_1080(nv12) + if picked: + w, h, f = picked + cases.append(VideoInputCase("csi_mipi_nv12", input_class, path, "NV12", w, h, f)) + return cases + + mjpeg = self._find_format(device, "MJPEG") + yuyv = self._find_format(device, "YUYV") + target_fps = 60 if input_class == "usb3" else 30 + if mjpeg: + picked = self._pick_exact(mjpeg, 1920, 1080, target_fps) or self._pick_highest_1080(mjpeg) + if picked: + w, h, f = picked + cases.append(VideoInputCase(f"{input_class}_mjpeg", input_class, path, "MJPEG", w, h, f)) + if yuyv: + picked = self._pick_highest_1080(yuyv) + if picked: + w, h, f = picked + cases.append(VideoInputCase(f"{input_class}_yuyv", input_class, path, "YUYV", w, h, f)) + return cases + + +class AcceptanceRunner: + def __init__(self, args: argparse.Namespace): + self.args = args + self.run_id = args.run_id or time.strftime("%Y%m%d-%H%M%S-") + uuid.uuid4().hex[:6] + self.reporter = Reporter(Path(args.report_dir) / self.run_id, color=not args.no_color) + self.api = ApiClient(args.target, args.http_port) + self.ssh_password = args.ssh_password or os.environ.get("OKVM_SSH_PASSWORD") + self.agent: AgentClient | None = None + self.lsusb_tree = "" + self.selected_hid_backend = "none" + self.screenshot_keys: set[str] = set() + + async def run(self) -> int: + if self.args.agent_host: + self.agent = AgentClient(self.args.agent_host, self.args.agent_port, self.reporter) + print(f"Connecting Windows agent at ws://{self.args.agent_host}:{self.args.agent_port}/agent") + + try: + if self.args.reset: + self.reset_target() + self.api.wait_health(timeout=self.args.health_timeout) + self.run_network_latency_test() + self.setup_or_login() + self.collect_target_inventory() + devices = self.api.get("/devices") + self.configure_hid_and_msd(devices) + video_cases = self.select_video_cases(devices) + await self.wait_for_agent_if_requested() + await self.capture_key_screenshot("console_after_login", "登录后控制台首页") + await self.run_video_matrix(video_cases) + await self.run_hdmi_capture_tests(video_cases) + await self.run_hid_test() + await self.run_msd_test() + self.run_atx_api_test() + await self.capture_key_screenshot("console_after_io", "HID/MSD/ATX 后控制台状态") + self.collect_logs() + finally: + if self.agent: + await self.agent.stop() + self.api.close() + self.reporter.write(self.run_id, self.args.target) + + return 1 if any(r.status == "FAIL" for r in self.reporter.results) else 0 + + def reset_target(self) -> None: + if not self.ssh_password and self.args.ssh_password_prompt: + self.ssh_password = getpass.getpass(f"SSH password for {self.args.ssh_user}@{self.args.target}: ") + ssh = SSHRunner(self.args.target, self.args.ssh_user, self.ssh_password, self.args.ssh_port) + data_dir = self.args.data_dir + cmd = f""" +set -eu +RUN_ID='{shell_quote(self.run_id)}' +DATA_DIR='{shell_quote(data_dir)}' +BACKUP="$DATA_DIR/test-backups/$RUN_ID" +mkdir -p "$BACKUP" +(systemctl stop one-kvm || service one-kvm stop || true) +if [ -f "$DATA_DIR/one-kvm.db" ]; then cp -a "$DATA_DIR/one-kvm.db" "$BACKUP/one-kvm.db"; fi +if [ -f "$DATA_DIR/one-kvm.db-wal" ]; then cp -a "$DATA_DIR/one-kvm.db-wal" "$BACKUP/one-kvm.db-wal"; fi +if [ -f "$DATA_DIR/one-kvm.db-shm" ]; then cp -a "$DATA_DIR/one-kvm.db-shm" "$BACKUP/one-kvm.db-shm"; fi +rm -f "$DATA_DIR/one-kvm.db" "$DATA_DIR/one-kvm.db-wal" "$DATA_DIR/one-kvm.db-shm" +(systemctl start one-kvm || service one-kvm start || nohup /usr/bin/one-kvm >"/tmp/one-kvm-test-$RUN_ID.log" 2>&1 &) +echo "$BACKUP" +""" + code, out, err = ssh.run(cmd, timeout=60) + if code != 0: + self.reporter.add("target_reset", "FAIL", "failed to reset target", stdout=out, stderr=err) + raise RuntimeError(err or out) + self.reporter.add("target_reset", "PASS", "database backed up and service restarted", backup=out.strip()) + + def setup_or_login(self) -> None: + setup = self.api.get("/setup") + if setup.get("needs_setup"): + self.api.post( + "/setup/init", + { + "username": self.args.web_user, + "password": self.args.web_password, + "hid_backend": "none", + "msd_enabled": False, + "ttyd_enabled": False, + "rustdesk_enabled": False, + }, + ) + self.reporter.add("setup_init", "PASS", "initial setup completed") + else: + self.reporter.add("setup_init", "WARN", "target already initialized; using login path") + self.authenticate("login", "authenticated with One-KVM") + + def authenticate(self, check_name: str, detail: str) -> None: + self.api.post("/auth/login", {"username": self.args.web_user, "password": self.args.web_password}) + self.reporter.add(check_name, "PASS", detail) + + def run_network_latency_test(self) -> None: + samples = max(1, int(self.args.network_latency_samples)) + timeout = max(0.1, float(self.args.network_latency_timeout)) + tcp_values: list[float] = [] + http_values: list[float] = [] + errors: list[str] = [] + + for _ in range(samples): + start = time.perf_counter_ns() + try: + with socket.create_connection((self.args.target, self.args.http_port), timeout=timeout): + pass + tcp_values.append((time.perf_counter_ns() - start) / 1_000_000) + except Exception as exc: + errors.append(f"tcp_connect: {exc}") + time.sleep(0.05) + + for _ in range(samples): + start = time.perf_counter_ns() + try: + self.api.get("/health") + http_values.append((time.perf_counter_ns() - start) / 1_000_000) + except Exception as exc: + errors.append(f"http_health: {exc}") + time.sleep(0.05) + + tcp_stats = latency_stats(tcp_values) + http_stats = latency_stats(http_values) + if tcp_values: + self.reporter.metric("network_tcp_connect_p50", round(float(tcp_stats["p50_ms"]), 2), "ms") + self.reporter.metric("network_tcp_connect_p95", round(float(tcp_stats["p95_ms"]), 2), "ms") + self.reporter.metric("network_tcp_connect_max", round(float(tcp_stats["max_ms"]), 2), "ms") + if http_values: + self.reporter.metric("network_http_health_p50", round(float(http_stats["p50_ms"]), 2), "ms") + self.reporter.metric("network_http_health_p95", round(float(http_stats["p95_ms"]), 2), "ms") + self.reporter.metric("network_http_health_max", round(float(http_stats["max_ms"]), 2), "ms") + + if tcp_values and http_values and not errors: + status = "PASS" + elif tcp_values or http_values: + status = "WARN" + else: + status = "FAIL" + self.reporter.add( + "network_latency", + status, + "measured controller-to-target TCP and HTTP latency" if status != "FAIL" else "failed to measure network latency", + target=self.args.target, + port=self.args.http_port, + samples=samples, + tcp_connect=tcp_stats, + http_health=http_stats, + errors=errors[:10], + ) + + def collect_target_inventory(self) -> None: + if not self.ssh_password and self.args.ssh_password_prompt: + self.ssh_password = getpass.getpass(f"SSH password for {self.args.ssh_user}@{self.args.target}: ") + ssh = SSHRunner(self.args.target, self.args.ssh_user, self.ssh_password, self.args.ssh_port) + commands = { + "lsusb-tree.txt": "lsusb -t || true", + "uname.txt": "uname -a || true", + "systemctl-status.txt": "systemctl status one-kvm --no-pager || true", + } + for filename, cmd in commands.items(): + code, out, err = ssh.run(cmd, timeout=30) + text = out + ("\nSTDERR:\n" + err if err else "") + path = self.reporter.report_dir / "evidence" / filename + path.write_text(text, encoding="utf-8") + self.reporter.add_evidence("目标机清单", filename, path) + if filename == "lsusb-tree.txt": + self.lsusb_tree = out + self.reporter.add("target_inventory", "PASS", "collected lsusb/system evidence") + + def configure_hid_and_msd(self, devices: dict[str, Any]) -> None: + udc = devices.get("udc", []) + serial = devices.get("serial", []) + if udc: + udc_name = udc[0]["name"] + self.api.patch( + "/config/hid", + { + "backend": "otg", + "otg_udc": udc_name, + "otg_profile": "full", + "otg_endpoint_budget": "auto", + "otg_keyboard_leds": False, + "mouse_absolute": True, + }, + ) + self.selected_hid_backend = "otg" + try: + self.api.patch("/config/msd", {"enabled": True}) + except Exception as exc: + if not self.msd_available(): + self.reporter.add("hid_msd_config", "WARN", f"configured OTG HID but failed to enable MSD: {exc}", udc=udc_name) + return + self.reporter.add("hid_msd_config", "PASS", "configured OTG HID and enabled MSD", udc=udc_name) + if not self.args.no_ventoy_sync: + try: + self.sync_ventoy_resources() + except Exception as exc: + self.reporter.add("ventoy_resources", "WARN", f"failed to sync Ventoy resources: {exc}") + if not self.args.no_msd_restart_after_enable: + try: + self.restart_target_service("msd_restart", "restarted One-KVM after enabling MSD") + except Exception as exc: + self.reporter.add("msd_restart", "WARN", f"failed to restart after enabling MSD: {exc}") + return + if serial: + port = serial[0]["path"] + self.api.patch( + "/config/hid", + { + "backend": "ch9329", + "ch9329_port": port, + "ch9329_baudrate": 9600, + "mouse_absolute": True, + }, + ) + self.selected_hid_backend = "ch9329" + self.reporter.add("hid_msd_config", "WARN", "configured CH9329 HID; MSD requires OTG and is skipped", port=port) + return + self.selected_hid_backend = "none" + self.reporter.add("hid_msd_config", "FAIL", "no UDC or CH9329 serial HID device found") + + def msd_available(self) -> bool: + try: + status = self.api.get("/msd/status") + except Exception: + return False + state = status.get("state") if isinstance(status, dict) else None + return bool( + isinstance(status, dict) + and ( + status.get("available") + or (isinstance(state, dict) and state.get("available")) + ) + ) + + def restart_target_service(self, check_name: str, detail: str) -> None: + if not self.ssh_password and self.args.ssh_password_prompt: + self.ssh_password = getpass.getpass(f"SSH password for {self.args.ssh_user}@{self.args.target}: ") + ssh = SSHRunner(self.args.target, self.args.ssh_user, self.ssh_password, self.args.ssh_port) + code, out, err = ssh.run("systemctl restart one-kvm || service one-kvm restart", timeout=90) + if code != 0: + raise RuntimeError(err or out or "service restart command failed") + self.api.wait_health(timeout=self.args.health_timeout) + self.authenticate("login_after_restart", "authenticated after One-KVM restart") + self.reporter.add(check_name, "PASS", detail) + + def sync_ventoy_resources(self) -> None: + source_dir = Path(self.args.ventoy_resources_dir) if self.args.ventoy_resources_dir else default_ventoy_resources_dir() + if not source_dir.exists(): + self.reporter.add("ventoy_resources", "WARN", f"local Ventoy resources not found: {source_dir}") + return + + remote_dir = self.args.data_dir.rstrip("/") + "/ventoy" + required = ("boot.img", "core.img", "ventoy.disk.img") + test_cmd = " && ".join(f"test -s {shell_arg(remote_dir + '/' + name)}" for name in required) + ssh = SSHRunner(self.args.target, self.args.ssh_user, self.ssh_password, self.args.ssh_port) + code, _, _ = ssh.run(test_cmd, timeout=20) + if code == 0: + self.reporter.add("ventoy_resources", "PASS", "Ventoy resources already present on target", path=remote_dir) + return + + code, out, err = ssh.run(f"mkdir -p {shell_arg(remote_dir)}", timeout=30) + if code != 0: + raise RuntimeError(err or out or f"failed to create {remote_dir}") + + client = ssh.connect() + try: + sftp = client.open_sftp() + try: + for name in required: + local_plain = source_dir / name + local_xz = source_dir / f"{name}.xz" + remote_path = remote_dir + "/" + name + if local_plain.exists(): + sftp.put(str(local_plain), remote_path) + elif local_xz.exists(): + with lzma.open(local_xz, "rb") as src, sftp.open(remote_path, "wb") as dst: + while True: + chunk = src.read(1024 * 1024) + if not chunk: + break + dst.write(chunk) + else: + raise FileNotFoundError(f"{local_plain} or {local_xz}") + finally: + sftp.close() + finally: + client.close() + self.reporter.add("ventoy_resources", "PASS", "synced Ventoy resources to target", source=str(source_dir), path=remote_dir) + + def select_video_cases(self, devices: dict[str, Any]) -> list[VideoInputCase]: + selector = DeviceSelector(self.lsusb_tree, devices) + cases = selector.select() + if not cases: + self.reporter.add("video_input_select", "FAIL", "no suitable video input case found", devices=devices.get("video", [])) + return [] + self.reporter.add("video_input_select", "PASS", "selected video test cases", cases=[c.__dict__ for c in cases]) + return cases + + async def wait_for_agent_if_requested(self) -> None: + if not self.agent: + self.reporter.add("windows_agent", "SKIP", "agent host not configured") + return + if not await self.agent.wait_connected(timeout=self.args.agent_timeout): + self.reporter.add("windows_agent", "FAIL", "Windows agent did not connect before timeout") + + async def capture_video_screenshot(self, output_mode: str) -> None: + titles = { + "mjpeg": "MJPEG 模式网页截图", + "h264": "H264 WebRTC 模式网页截图", + "h265": "H265 WebRTC 尝试网页截图", + } + await self.capture_key_screenshot( + f"video_{output_mode}", + titles.get(output_mode, f"{output_mode} 网页截图"), + wait_video=True, + ) + + async def capture_key_screenshot(self, key: str, title: str, path: str = "/", wait_video: bool = False) -> None: + if self.args.no_screenshots or key in self.screenshot_keys: + return + self.screenshot_keys.add(key) + try: + from playwright.async_api import async_playwright + except ImportError: + self.reporter.add("screenshot", "WARN", "playwright is not installed; screenshot skipped", key=key) + return + + output_dir = self.reporter.report_dir / "evidence" / "screenshots" + output_dir.mkdir(parents=True, exist_ok=True) + output_path = output_dir / f"{key}.png" + try: + async with async_playwright() as p: + browser = await p.chromium.launch(**self.chromium_launch_kwargs()) + context = await browser.new_context(ignore_https_errors=True, viewport={"width": 1440, "height": 960}) + for part in self.api.cookie_header().split("; "): + if not part or "=" not in part: + continue + name, value = part.split("=", 1) + await context.add_cookies([{"name": name, "value": value, "url": self.api.base}]) + page = await context.new_page() + await page.goto(self.api.base + path, wait_until="domcontentloaded", timeout=15000) + if wait_video: + await self.wait_for_video_screenshot_ready(page) + else: + await page.wait_for_timeout(1500) + await page.screenshot(path=str(output_path), full_page=True) + await browser.close() + self.reporter.add_evidence("网页截图", title, output_path) + except Exception as exc: + if is_playwright_runtime_error(str(exc).lower()): + self.reporter.add("screenshot", "WARN", f"Playwright {CHROME_BROWSER_NAME} cannot start; screenshot skipped: {exc}", key=key) + return + self.reporter.add("screenshot", "WARN", f"failed to capture screenshot {key}: {exc}", key=key) + + async def wait_for_video_screenshot_ready(self, page: Any) -> None: + timeout = max(0, int(self.args.screenshot_wait_ms)) + if timeout <= 0: + return + try: + await page.wait_for_function(VIDEO_SCREENSHOT_READY_JS, timeout=timeout) + except Exception: + await page.wait_for_timeout(min(timeout, 1500)) + + def chromium_launch_kwargs(self) -> dict[str, Any]: + return { + "headless": True, + "args": chromium_launch_args(), + "channel": CHROME_BROWSER_CHANNEL, + } + + async def run_video_matrix(self, cases: list[VideoInputCase]) -> None: + if not cases: + return + codecs = self.available_codecs() + for case in cases: + self.configure_video_case(case) + for output_mode in ("mjpeg", "h264", "h265"): + if output_mode in ("h264", "h265") and output_mode not in codecs: + self.reporter.add( + f"video_{case.label}_{output_mode}", + "SKIP", + f"{output_mode} is not available", + case=case.__dict__, + ) + continue + try: + self.apply_video_case(case) + if output_mode == "mjpeg": + motion_started = await self.start_mjpeg_motion() + try: + result = self.measure_mjpeg(case) + if motion_started: + result["dynamic_source_fps"] = self.args.mjpeg_motion_fps + finally: + if motion_started: + await self.stop_mjpeg_motion() + else: + result = await self.measure_webrtc(case, output_mode) + status = self.video_result_status(result) + self.reporter.add(f"video_{case.label}_{output_mode}", status, result.get("message", ""), **result) + await self.capture_video_screenshot(output_mode) + except Exception as exc: + status = "SKIP" if self.is_environment_skip(exc, output_mode) else "FAIL" + self.reporter.add(f"video_{case.label}_{output_mode}", status, str(exc), case=case.__dict__) + await self.capture_video_screenshot(output_mode) + + async def start_mjpeg_motion(self) -> bool: + if not self.agent: + self.reporter.add("mjpeg_motion_source", "WARN", "Windows agent not connected; MJPEG fps may be affected by static-frame suppression") + return False + try: + await self.agent.command("start_dynamic", {"fps": self.args.mjpeg_motion_fps}, timeout=5) + return True + except Exception as exc: + self.reporter.add("mjpeg_motion_source", "WARN", f"failed to start dynamic MJPEG source: {exc}") + return False + + async def stop_mjpeg_motion(self) -> None: + if not self.agent: + return + try: + await self.agent.command("stop_dynamic", {}, timeout=5) + except Exception as exc: + self.reporter.add("mjpeg_motion_source", "WARN", f"failed to stop dynamic MJPEG source: {exc}") + + def video_result_status(self, result: dict[str, Any]) -> str: + if result.get("unsupported"): + return "SKIP" + if result.get("ok"): + return "PASS" + return "FAIL" + + @staticmethod + def is_environment_skip(exc: Exception, output_mode: str) -> bool: + text = str(exc).lower() + if is_playwright_runtime_error(text): + return True + return output_mode == "h265" and is_webrtc_codec_unsupported_error(text) + + def set_stream_mode(self, mode: str, timeout: int = 45) -> dict[str, Any]: + deadline = time.monotonic() + timeout + last: Any = None + while time.monotonic() < deadline: + response = self.api.client.post("/api/stream/mode", json={"mode": mode}) + data = response.json() + last = data + if response.status_code >= 400: + raise RuntimeError(f"POST /stream/mode -> HTTP {response.status_code}: {data}") + if data.get("success") is False and not data.get("switching"): + raise RuntimeError(f"POST /stream/mode failed: {data}") + ready = self.wait_stream_mode_ready(mode, timeout=max(1, int(deadline - time.monotonic()))) + if ready: + return data + time.sleep(0.5) + raise TimeoutError(f"stream mode {mode} did not become ready; last={last}") + + def wait_stream_mode_ready(self, mode: str, timeout: int = 45) -> bool: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + try: + data = self.api.get("/stream/mode") + current = str(data.get("mode", "")).lower() + switching = bool(data.get("switching")) + if not switching and (current == mode or (mode == "webrtc" and current in {"h264", "h265", "vp8", "vp9"})): + return True + except Exception: + pass + time.sleep(0.5) + return False + + def available_codecs(self) -> set[str]: + try: + data = self.api.get("/stream/codecs") + return {c["id"] for c in data.get("codecs", []) if c.get("available")} + except Exception as exc: + self.reporter.add("stream_codecs", "WARN", f"failed to list codecs: {exc}") + return {"mjpeg", "h264"} + + def apply_video_case(self, case: VideoInputCase) -> None: + self.api.patch( + "/config/video", + { + "device": case.device, + "format": case.fmt, + "width": case.width, + "height": case.height, + "fps": int(round(case.fps)), + "quality": self.args.jpeg_quality, + }, + ) + + def configure_video_case(self, case: VideoInputCase) -> None: + self.apply_video_case(case) + self.reporter.add( + f"config_video_{case.label}", + "PASS", + f"{case.device} {case.fmt} {case.width}x{case.height}@{case.fps}", + case=case.__dict__, + ) + + def measure_mjpeg(self, case: VideoInputCase) -> dict[str, Any]: + self.set_stream_mode("mjpeg") + # One-KVM may prefer MJPEG capture when entering MJPEG/HTTP mode. Re-apply + # the requested input after the mode switch so YUYV/NV12 cases measure + # the intended capture format instead of the automatic MJPEG fallback. + self.apply_video_case(case) + self.api.post("/stream/start", {}) + client_id = f"test-{self.run_id}-{case.label}" + deadline = time.monotonic() + self.args.sample_seconds + frame_count = 0 + byte_count = 0 + first_frame_s: float | None = None + for frame, frame_time, _ in self.iter_mjpeg_frames(client_id, timeout=self.args.sample_seconds, video_case=case): + now = time.monotonic() + if now >= deadline: + break + if first_frame_s is None: + first_frame_s = frame_time + frame_count += 1 + byte_count += len(frame) + if frame_count == 0: + raise RuntimeError("MJPEG stream did not become available before sample timeout") + duration = self.args.sample_seconds + measured_fps = frame_count / duration if duration else 0.0 + self.reporter.metric("mjpeg_fps", round(measured_fps, 2), "fps", case=case.label) + self.reporter.metric("mjpeg_bytes", byte_count, "bytes", case=case.label) + min_fps = min(case.fps * 0.8, case.fps - 1) if case.fps >= 5 else case.fps * 0.8 + functional = frame_count > 0 + degraded = self.args.strict_performance and functional and measured_fps < min_fps + message = f"MJPEG {measured_fps:.1f} fps over {duration}s" + if degraded: + message += f" (below expected {min_fps:.1f} fps)" + result = { + "ok": functional and not degraded, + "degraded": degraded, + "message": message, + "case": case.__dict__, + "frames": frame_count, + "fps": measured_fps, + "input_requested_fps": case.fps, + "first_frame_ms": None if first_frame_s is None else max(0, (first_frame_s - (deadline - duration)) * 1000), + "bytes": byte_count, + } + if self.args.strict_performance: + result["min_expected_fps"] = min_fps + return result + + async def measure_webrtc(self, case: VideoInputCase, codec: str) -> dict[str, Any]: + self.set_stream_mode(codec) + try: + from playwright.async_api import async_playwright + except ImportError: + return {"ok": False, "message": "playwright is not installed", "case": case.__dict__, "codec": codec} + + cookie_header = self.api.cookie_header() + js = WEBRTC_MEASURE_JS + async with async_playwright() as p: + try: + browser = await p.chromium.launch(**self.chromium_launch_kwargs()) + except Exception as exc: + text = str(exc).lower() + if is_playwright_runtime_error(text): + return { + "ok": False, + "unsupported": True, + "message": f"Playwright {CHROME_BROWSER_NAME} cannot start; run: {CHROME_INSTALL_COMMAND}", + "case": case.__dict__, + "codec": codec, + } + raise + context = await browser.new_context(ignore_https_errors=True) + for part in cookie_header.split("; "): + if not part or "=" not in part: + continue + name, value = part.split("=", 1) + await context.add_cookies([{"name": name, "value": value, "url": self.api.base}]) + try: + page = await context.new_page() + await page.goto(self.api.base) + result = await page.evaluate(js, {"seconds": self.args.sample_seconds}) + except Exception as exc: + text = str(exc) + if codec == "h265" and is_webrtc_codec_unsupported_error(text): + return { + "ok": False, + "unsupported": True, + "message": "H.265 WebRTC appears unsupported by browser or streamer", + "case": case.__dict__, + "codec": codec, + "error": text, + } + raise + finally: + await browser.close() + fps = float(result.get("avgFps") or 0) + rtt_ms = float(result.get("maxRtt") or 0) * 1000 + jitter_ms = float(result.get("maxJitter") or 0) * 1000 + self.reporter.metric("webrtc_fps", round(fps, 2), "fps", case=case.label, codec=codec) + self.reporter.metric("webrtc_rtt_max", round(rtt_ms, 2), "ms", case=case.label, codec=codec) + self.reporter.metric("webrtc_jitter_max", round(jitter_ms, 2), "ms", case=case.label, codec=codec) + unsupported = codec == "h265" and int(result.get("framesDecoded") or 0) == 0 and fps == 0 + min_fps = max(1.0, min(case.fps * 0.75, case.fps - 2)) + functional = bool(result.get("connected")) and fps > 0 + degraded = self.args.strict_performance and functional and fps < min_fps + ok = (not unsupported) and functional and not degraded + message = "H.265 WebRTC appears unsupported by browser or decoder" if unsupported else f"{codec} WebRTC avg {fps:.1f} fps, max RTT {rtt_ms:.1f} ms" + if degraded: + message += f" (below expected {min_fps:.1f} fps)" + measured = { + "ok": bool(ok), + "unsupported": unsupported, + "degraded": degraded, + "message": message, + "case": case.__dict__, + "codec": codec, + "input_requested_fps": case.fps, + **result, + } + if self.args.strict_performance: + measured["min_expected_fps"] = min_fps + return measured + + async def run_hdmi_capture_tests(self, cases: list[VideoInputCase]) -> None: + if self.args.no_hdmi_tests: + return + if not self.agent: + for name in ("hdmi_identity", "hdmi_color_range"): + self.reporter.add(name, "SKIP", "Windows agent not connected") + for case in cases: + for output_mode in VIDEO_LATENCY_OUTPUT_MODES: + self.reporter.add( + self.video_latency_check_name(case, output_mode), + "SKIP", + "Windows agent not connected", + video_case=case.__dict__, + output_mode=output_mode, + ) + return + case = self.pick_hdmi_case(cases) + if not case: + for name in ("hdmi_identity", "hdmi_color_range"): + self.reporter.add(name, "SKIP", "no video input case available") + for output_mode in VIDEO_LATENCY_OUTPUT_MODES: + self.reporter.add(f"video_latency_{output_mode}", "SKIP", "no video input case available", output_mode=output_mode) + return + + try: + self.configure_hdmi_probe_case(case) + except Exception as exc: + for name in ("hdmi_identity", "hdmi_color_range"): + self.reporter.add(name, "FAIL", f"failed to configure HDMI probe video case: {exc}") + for latency_case in cases: + for output_mode in VIDEO_LATENCY_OUTPUT_MODES: + self.reporter.add( + self.video_latency_check_name(latency_case, output_mode), + "FAIL", + f"failed to configure video case before latency test: {exc}", + video_case=latency_case.__dict__, + output_mode=output_mode, + ) + return + + try: + await self.run_hdmi_color_test(case) + except Exception as exc: + self.reporter.add("hdmi_identity", "FAIL", str(exc)) + self.reporter.add("hdmi_color_range", "FAIL", str(exc)) + + codecs = self.available_codecs() + for latency_case in cases: + for output_mode in VIDEO_LATENCY_OUTPUT_MODES: + check_name = self.video_latency_check_name(latency_case, output_mode) + if output_mode in ("h264", "h265") and output_mode not in codecs: + self.reporter.add( + check_name, + "SKIP", + f"{output_mode} is not available", + video_case=latency_case.__dict__, + output_mode=output_mode, + ) + continue + try: + self.apply_video_case(latency_case) + if output_mode == "mjpeg": + await self.run_mjpeg_latency_test(latency_case, check_name=check_name, save_evidence=False) + else: + await self.run_webrtc_latency_test(latency_case, output_mode, check_name=check_name) + except Exception as exc: + status = "SKIP" if self.is_environment_skip(exc, output_mode) else "FAIL" + self.reporter.add(check_name, status, str(exc), video_case=latency_case.__dict__, output_mode=output_mode) + + @staticmethod + def pick_hdmi_case(cases: list[VideoInputCase]) -> VideoInputCase | None: + if not cases: + return None + for case in cases: + if case.fmt.upper() == "MJPEG" and case.width == 1920 and case.height == 1080: + return case + for case in cases: + if case.fmt.upper() == "MJPEG": + return case + return cases[0] + + def configure_hdmi_probe_case(self, case: VideoInputCase) -> None: + self.api.patch( + "/config/video", + { + "device": case.device, + "format": case.fmt, + "width": case.width, + "height": case.height, + "fps": int(round(case.fps)), + "quality": self.args.jpeg_quality, + }, + ) + self.reporter.add( + "config_video_hdmi_probe", + "PASS", + f"{case.device} {case.fmt} {case.width}x{case.height}@{case.fps}", + case=case.__dict__, + ) + + @staticmethod + def video_latency_check_name(case: VideoInputCase, output_mode: str) -> str: + return f"video_latency_{safe_filename(case.label)}_{safe_filename(output_mode)}" + + async def run_hdmi_color_test(self, case: VideoInputCase) -> None: + samples: list[dict[str, Any]] = [] + for name, color in HDMI_COLOR_SEQUENCE: + await self.agent.command("show", {"color": color, "full": True}, timeout=5) + await asyncio.sleep(self.args.hdmi_settle_ms / 1000) + expected = hex_to_rgb(color) + stats = self.capture_mjpeg_rgb_mean( + f"hdmi_color_{name}", + timeout=self.args.hdmi_capture_timeout, + evidence_title=f"HDMI 纯色采集帧 {name}", + expected_rgb=expected, + threshold=self.args.hdmi_color_fail, + video_case=case, + ) + err = rgb_error(expected, stats["mean_rgb"]) + closest_name, closest_error = closest_hdmi_color(stats["mean_rgb"]) + sample = { + "name": name, + "expected_hex": color, + "expected_rgb": expected, + "measured_rgb_mean": stats["mean_rgb"], + "measured_rgb_stddev": stats["stddev_rgb"], + "width": stats["width"], + "height": stats["height"], + "mean_abs_error": err["mean_abs_error"], + "max_abs_error": err["max_abs_error"], + "closest_color": closest_name, + "closest_error": closest_error, + "target_detected": bool(stats.get("target_detected")), + "frames_seen": int(stats.get("frames_seen") or 0), + } + sample["identity_match"] = self.hdmi_identity_match(sample) + samples.append(sample) + self.reporter.metric("hdmi_color_mean_abs_error", round(err["mean_abs_error"], 2), "rgb_level", color=name) + self.reporter.metric("hdmi_color_max_abs_error", round(err["max_abs_error"], 2), "rgb_level", color=name) + + identity_colors = [s for s in samples if s["name"] in {"red", "green", "blue"}] + identity_ok = bool(identity_colors) and all(bool(s["identity_match"]) for s in identity_colors) + identity_status = "PASS" if identity_ok else "FAIL" + self.reporter.add( + "hdmi_identity", + identity_status, + "HDMI capture matches Windows fullscreen color sequence" if identity_ok else "HDMI capture did not match Windows fullscreen color sequence", + samples=identity_colors, + video_case=case.__dict__, + ) + + max_mean_error = max((float(s["mean_abs_error"]) for s in samples), default=999.0) + max_abs_error = max((float(s["max_abs_error"]) for s in samples), default=999.0) + if max_mean_error <= self.args.hdmi_color_warn: + color_status = "PASS" + elif max_mean_error <= self.args.hdmi_color_fail: + color_status = "WARN" + else: + color_status = "FAIL" + self.reporter.add( + "hdmi_color_range", + color_status, + f"max mean RGB error {max_mean_error:.1f}, max channel error {max_abs_error:.1f}", + samples=samples, + warn_threshold=self.args.hdmi_color_warn, + fail_threshold=self.args.hdmi_color_fail, + video_case=case.__dict__, + ) + + @staticmethod + def hdmi_identity_match(sample: dict[str, Any]) -> bool: + name = str(sample["name"]) + mean = tuple(float(x) for x in sample["measured_rgb_mean"]) + if name == "red": + return mean[0] > 120 and mean[0] > mean[1] * 1.8 and mean[0] > mean[2] * 1.8 + if name == "green": + return mean[1] > 120 and mean[1] > mean[0] * 1.8 and mean[1] > mean[2] * 1.8 + if name == "blue": + return mean[2] > 120 and mean[2] > mean[0] * 1.8 and mean[2] > mean[1] * 1.8 + return float(sample["mean_abs_error"]) <= 60 + + async def run_mjpeg_latency_test(self, case: VideoInputCase, check_name: str = "hdmi_latency_mjpeg", save_evidence: bool = True) -> None: + if self.args.hdmi_latency_trials <= 0: + self.reporter.add(check_name, "SKIP", "video latency trials disabled", video_case=case.__dict__, output_mode="mjpeg") + return + offset_ns, sync = await self.sync_agent_clock(f"{check_name}_agent_clock_sync_rtt") + trials: list[dict[str, Any]] = [] + colors = [("#ff0000", "#00ff00"), ("#00ff00", "#0000ff"), ("#0000ff", "#ff0000")] + for i in range(self.args.hdmi_latency_trials): + source, target = colors[i % len(colors)] + await self.agent.command("show", {"color": source, "full": True}, timeout=5) + await asyncio.sleep(self.args.hdmi_settle_ms / 1000) + await self.agent.command("schedule_color", {"color": target, "delay_ms": self.args.hdmi_latency_delay_ms}, timeout=5) + detect_timeout = (self.args.hdmi_latency_delay_ms + self.args.hdmi_latency_timeout_ms) / 1000 + detected = self.detect_mjpeg_color( + hex_to_rgb(target), + f"{check_name}_{i}", + timeout=detect_timeout, + threshold=self.args.hdmi_color_fail, + evidence_title=f"HDMI 延迟命中帧 {case.label} #{i + 1}" if save_evidence else None, + video_case=case, + ) + display = await self.agent.command("display_state", {}, timeout=5) + actual_agent_ns = int(display.get("last_change_unix_nano") or 0) + actual_linux_ns = actual_agent_ns - offset_ns + latency_ms = (int(detected["wall_ns"]) - actual_linux_ns) / 1_000_000 + trial = { + "trial": i + 1, + "source": source, + "target": target, + "latency_ms": latency_ms, + "detected_rgb_mean": detected["mean_rgb"], + "mean_abs_error": detected["mean_abs_error"], + "detected_wall_ns": detected["wall_ns"], + "agent_change_unix_nano": actual_agent_ns, + "clock_sync": sync, + } + trials.append(trial) + self.reporter.metric(check_name, round(latency_ms, 2), "ms", trial=i + 1, target=target, case=case.label) + + latencies = [float(t["latency_ms"]) for t in trials] + p50 = percentile(latencies, 50) + p95 = percentile(latencies, 95) + max_latency = max(latencies) if latencies else 0.0 + self.reporter.metric(f"{check_name}_p50", round(p50, 2), "ms", case=case.label) + self.reporter.metric(f"{check_name}_p95", round(p95, 2), "ms", case=case.label) + self.reporter.metric(f"{check_name}_max", round(max_latency, 2), "ms", case=case.label) + if not trials: + status = "FAIL" + elif p95 <= self.args.hdmi_latency_fail_ms: + status = "PASS" + else: + status = "FAIL" + self.reporter.add( + check_name, + status, + f"MJPEG visual latency for {case.label}: p50 {p50:.1f} ms, p95 {p95:.1f} ms, max {max_latency:.1f} ms", + trials=trials, + p50_ms=p50, + p95_ms=p95, + max_ms=max_latency, + functional_threshold_ms=self.args.hdmi_latency_fail_ms, + video_case=case.__dict__, + output_mode="mjpeg", + ) + + async def run_webrtc_latency_test(self, case: VideoInputCase, output_mode: str, check_name: str) -> None: + if self.args.hdmi_latency_trials <= 0: + self.reporter.add(check_name, "SKIP", "video latency trials disabled", video_case=case.__dict__, output_mode=output_mode) + return + self.set_stream_mode(output_mode) + self.apply_video_case(case) + try: + from playwright.async_api import async_playwright + except ImportError as exc: + raise RuntimeError("playwright is required for WebRTC visual latency tests; run pip install -r requirements.txt") from exc + + offset_ns, sync = await self.sync_agent_clock(f"{check_name}_agent_clock_sync_rtt") + cookie_header = self.api.cookie_header() + trials: list[dict[str, Any]] = [] + colors = [("#ff0000", "#00ff00"), ("#00ff00", "#0000ff"), ("#0000ff", "#ff0000")] + async with async_playwright() as p: + try: + browser = await p.chromium.launch(**self.chromium_launch_kwargs()) + except Exception as exc: + text = str(exc).lower() + if is_playwright_runtime_error(text): + raise RuntimeError(f"Playwright {CHROME_BROWSER_NAME} cannot start; run: {CHROME_INSTALL_COMMAND}") from exc + raise + context = await browser.new_context(ignore_https_errors=True) + for part in cookie_header.split("; "): + if not part or "=" not in part: + continue + name, value = part.split("=", 1) + await context.add_cookies([{"name": name, "value": value, "url": self.api.base}]) + page = await context.new_page() + try: + await page.goto(self.api.base, wait_until="domcontentloaded", timeout=15000) + setup = await page.evaluate(WEBRTC_LATENCY_SETUP_JS, {"timeoutMs": 15000}) + if not setup.get("connected"): + raise RuntimeError(f"{output_mode} WebRTC did not connect: {setup}") + for i in range(self.args.hdmi_latency_trials): + source, target = colors[i % len(colors)] + await self.agent.command("show", {"color": source, "full": True}, timeout=5) + await asyncio.sleep(self.args.hdmi_settle_ms / 1000) + source_seen = await page.evaluate( + WEBRTC_COLOR_DETECT_JS, + { + "targetRgb": hex_to_rgb(source), + "timeoutMs": max(1000, self.args.hdmi_settle_ms + 2000), + "threshold": self.args.hdmi_color_fail, + }, + ) + if not source_seen.get("target_detected"): + raise RuntimeError(f"{output_mode} WebRTC did not show source color before latency trial: {source_seen}") + + detect_timeout_ms = self.args.hdmi_latency_delay_ms + self.args.hdmi_latency_timeout_ms + detect_task = asyncio.create_task( + page.evaluate( + WEBRTC_COLOR_DETECT_JS, + { + "targetRgb": hex_to_rgb(target), + "timeoutMs": detect_timeout_ms, + "threshold": self.args.hdmi_color_fail, + }, + ) + ) + await self.agent.command("schedule_color", {"color": target, "delay_ms": self.args.hdmi_latency_delay_ms}, timeout=5) + detected = await detect_task + if not detected.get("target_detected"): + raise RuntimeError(f"{output_mode} WebRTC target color not detected before timeout: {detected}") + display = await self.agent.command("display_state", {}, timeout=5) + actual_agent_ns = int(display.get("last_change_unix_nano") or 0) + actual_linux_ns = actual_agent_ns - offset_ns + latency_ms = (int(detected["wall_ns"]) - actual_linux_ns) / 1_000_000 + trial = { + "trial": i + 1, + "source": source, + "target": target, + "latency_ms": latency_ms, + "detected_rgb_mean": detected["mean_rgb"], + "mean_abs_error": detected["mean_abs_error"], + "detected_wall_ns": detected["wall_ns"], + "agent_change_unix_nano": actual_agent_ns, + "clock_sync": sync, + } + trials.append(trial) + self.reporter.metric(check_name, round(latency_ms, 2), "ms", trial=i + 1, target=target, case=case.label, codec=output_mode) + finally: + try: + await page.evaluate(WEBRTC_LATENCY_CLOSE_JS) + except Exception: + pass + await browser.close() + + latencies = [float(t["latency_ms"]) for t in trials] + p50 = percentile(latencies, 50) + p95 = percentile(latencies, 95) + max_latency = max(latencies) if latencies else 0.0 + self.reporter.metric(f"{check_name}_p50", round(p50, 2), "ms", case=case.label, codec=output_mode) + self.reporter.metric(f"{check_name}_p95", round(p95, 2), "ms", case=case.label, codec=output_mode) + self.reporter.metric(f"{check_name}_max", round(max_latency, 2), "ms", case=case.label, codec=output_mode) + status = "PASS" if trials and p95 <= self.args.hdmi_latency_fail_ms else "FAIL" + self.reporter.add( + check_name, + status, + f"{output_mode} visual latency for {case.label}: p50 {p50:.1f} ms, p95 {p95:.1f} ms, max {max_latency:.1f} ms", + trials=trials, + p50_ms=p50, + p95_ms=p95, + max_ms=max_latency, + functional_threshold_ms=self.args.hdmi_latency_fail_ms, + video_case=case.__dict__, + output_mode=output_mode, + ) + + async def sync_agent_clock(self, metric_name: str = "agent_clock_sync_rtt") -> tuple[int, dict[str, Any]]: + best: tuple[int, int, dict[str, Any]] | None = None + for _ in range(7): + t0 = time.time_ns() + payload = await self.agent.command("ping", {}, timeout=5) + t1 = time.time_ns() + rtt = t1 - t0 + midpoint = (t0 + t1) // 2 + offset = int(payload.get("unix_nano") or 0) - midpoint + detail = { + "offset_ns": offset, + "rtt_ns": rtt, + "offset_ms": offset / 1_000_000, + "rtt_ms": rtt / 1_000_000, + } + if best is None or rtt < best[0]: + best = (rtt, offset, detail) + await asyncio.sleep(0.05) + assert best is not None + self.reporter.metric(metric_name, round(best[2]["rtt_ms"], 3), "ms") + return best[1], best[2] + + def capture_mjpeg_rgb_mean( + self, + client_label: str, + timeout: float = 6.0, + evidence_title: str | None = None, + expected_rgb: tuple[int, int, int] | None = None, + threshold: float | None = None, + video_case: VideoInputCase | None = None, + ) -> dict[str, Any]: + threshold = self.args.hdmi_color_fail if threshold is None else threshold + required_matches = max(1, int(self.args.hdmi_match_frames)) + best: dict[str, Any] | None = None + best_frame: bytes | None = None + last: dict[str, Any] | None = None + last_frame: bytes | None = None + consecutive_matches = 0 + frames_seen = 0 + + for frame, _, wall_ns in self.iter_mjpeg_frames(client_label, timeout, video_case=video_case): + frames_seen += 1 + stats = jpeg_rgb_stats(frame) + stats["wall_ns"] = wall_ns + stats["frames_seen"] = frames_seen + last = stats + last_frame = frame + if expected_rgb is None: + stats["target_detected"] = True + if evidence_title: + self.save_frame_evidence(client_label, evidence_title, frame) + return stats + + err = rgb_error(expected_rgb, stats["mean_rgb"]) + stats.update(err) + if best is None or float(err["mean_abs_error"]) < float(best.get("mean_abs_error", 999.0)): + best = dict(stats) + best_frame = frame + + if float(err["mean_abs_error"]) <= threshold: + consecutive_matches += 1 + if consecutive_matches >= required_matches: + stats["target_detected"] = True + if evidence_title: + self.save_frame_evidence(client_label, evidence_title, frame) + return stats + else: + consecutive_matches = 0 + + chosen = best or last + chosen_frame = best_frame or last_frame + if chosen is not None and chosen_frame is not None: + chosen["target_detected"] = False + chosen["frames_seen"] = frames_seen + if evidence_title: + self.save_frame_evidence(client_label, evidence_title, chosen_frame) + return chosen + raise RuntimeError(f"no MJPEG frame captured for {client_label}") + + def detect_mjpeg_color( + self, + expected_rgb: tuple[int, int, int], + client_label: str, + timeout: float, + threshold: float, + evidence_title: str | None = None, + video_case: VideoInputCase | None = None, + ) -> dict[str, Any]: + last: dict[str, Any] | None = None + last_frame: bytes | None = None + frames_seen = 0 + for frame, _, wall_ns in self.iter_mjpeg_frames(client_label, timeout, video_case=video_case): + frames_seen += 1 + stats = jpeg_rgb_stats(frame) + err = rgb_error(expected_rgb, stats["mean_rgb"]) + stats.update(err) + stats["wall_ns"] = wall_ns + stats["frames_seen"] = frames_seen + last = stats + last_frame = frame + if float(err["mean_abs_error"]) <= threshold: + stats["target_detected"] = True + if evidence_title: + self.save_frame_evidence(client_label, evidence_title, frame) + return stats + if last_frame is not None and evidence_title: + self.save_frame_evidence(f"{client_label}_last", f"{evidence_title}(未命中末帧)", last_frame) + raise RuntimeError(f"target HDMI color not detected before timeout; last={last}") + + def save_frame_evidence(self, label: str, title: str, frame: bytes) -> None: + output_dir = self.reporter.report_dir / "evidence" / "hdmi-frames" + output_dir.mkdir(parents=True, exist_ok=True) + path = output_dir / f"{safe_filename(label)}.jpg" + path.write_bytes(frame) + self.reporter.add_evidence("HDMI 采集帧", title, path) + + def iter_mjpeg_frames(self, client_label: str, timeout: float, video_case: VideoInputCase | None = None): + self.set_stream_mode("mjpeg") + if video_case is not None: + self.apply_video_case(video_case) + self.api.post("/stream/start", {}) + client_id = f"test-{self.run_id}-{client_label}" + url = f"{self.api.base}/api/stream/mjpeg?client_id={client_id}" + deadline = time.monotonic() + timeout + buf = b"" + while time.monotonic() < deadline: + try: + stream_timeout = httpx.Timeout(timeout=max(1.0, timeout), connect=5.0, read=1.0, write=5.0, pool=5.0) + with self.api.client.stream("GET", url, timeout=stream_timeout) as response: + response.raise_for_status() + for chunk in response.iter_bytes(): + if time.monotonic() >= deadline: + return + if not chunk: + continue + buf += chunk + while True: + soi = buf.find(b"\xff\xd8") + if soi < 0: + buf = buf[-2:] + break + eoi = buf.find(b"\xff\xd9", soi + 2) + if eoi < 0: + buf = buf[soi:] + if len(buf) > 8 * 1024 * 1024: + buf = buf[-1024 * 1024:] + break + frame = buf[soi : eoi + 2] + buf = buf[eoi + 2 :] + yield frame, time.monotonic(), time.time_ns() + except httpx.ReadTimeout: + continue + except httpx.HTTPStatusError as exc: + if exc.response.status_code != 503: + raise + time.sleep(0.5) + self.set_stream_mode("mjpeg", timeout=10) + if video_case is not None: + self.apply_video_case(video_case) + self.api.post("/stream/start", {}) + + async def run_hid_test(self) -> None: + try: + status = self.api.get("/hid/status") + except Exception as exc: + self.reporter.add("hid_status", "FAIL", str(exc)) + return + if not status.get("available"): + self.reporter.add("hid_status", "FAIL", "HID is not available", status=status) + return + self.reporter.add("hid_status", "PASS", "HID backend is available", status=status) + if not self.agent: + self.reporter.add("hid_input", "SKIP", "Windows agent not connected") + self.reporter.add("hid_latency", "SKIP", "Windows agent not connected") + return + try: + keyboard_events = await self.run_hid_keyboard_matrix() + keyboard = self.evaluate_hid_keyboard_events(keyboard_events) + mouse = await self.run_hid_mouse_matrix() + event_count = len(keyboard_events) + len(mouse.get("events", [])) + ok = bool(keyboard.get("ok")) and bool(mouse.get("ok")) + message = ( + "HID matrix passed: alphanumeric, function keys, safe combos, absolute/relative mouse" + if ok + else "HID matrix failed; see missing key/mouse details" + ) + sample_events = [*keyboard_events[:60], *(mouse.get("events", [])[:20])] + self.reporter.add( + "hid_input", + "PASS" if ok else "FAIL", + message, + event_count=event_count, + chars=keyboard.get("chars", ""), + keyboard=keyboard, + mouse={k: v for k, v in mouse.items() if k != "events"}, + events=sample_events, + ) + except Exception as exc: + self.reporter.add("hid_input", "FAIL", str(exc)) + try: + await self.run_hid_latency_test() + except Exception as exc: + self.reporter.add("hid_latency", "FAIL", str(exc)) + + async def connect_hid_websocket(self) -> Any: + try: + import websockets + except ImportError as exc: + raise RuntimeError("websockets is required for HID test") from exc + + headers = {"Cookie": self.api.cookie_header()} + uri = f"ws://{self.args.target}:{self.args.http_port}/api/ws/hid" + header_arg = "additional_headers" if "additional_headers" in inspect.signature(websockets.connect).parameters else "extra_headers" + connect_kwargs = {"max_size": 1024 * 1024, header_arg: headers} + ws = await websockets.connect(uri, **connect_kwargs) + initial = await ws.recv() + if isinstance(initial, str) or not initial or initial[0] != 0: + await ws.close() + raise RuntimeError(f"HID WebSocket unavailable: {initial!r}") + return ws + + async def run_hid_keyboard_matrix(self) -> list[dict[str, Any]]: + if not self.agent: + return [] + await self.agent.command("begin_hid_capture", {}, timeout=10) + await asyncio.sleep(0.3) + ws = await self.connect_hid_websocket() + try: + for ch in HID_ALNUM_TEXT: + usage, mod = HID_KEY_USAGE[ch] + await self.send_hid_key(ws, usage, mod) + for _, usage, _ in HID_FUNCTION_KEYS: + await self.send_hid_key(ws, usage, 0) + for _, usage, _, mod in HID_SAFE_COMBOS: + await self.send_hid_key(ws, usage, mod) + finally: + await ws.close() + await asyncio.sleep(1) + payload = await self.agent.command("get_hid_events", {}, timeout=10) + return payload.get("events", []) + + async def run_hid_mouse_matrix(self) -> dict[str, Any]: + if not self.agent: + return {"ok": False, "reason": "Windows agent not connected", "events": []} + + screen = ((self.agent.hello or {}).get("screen") or {}) if self.agent else {} + width = int(screen.get("width") or 0) + height = int(screen.get("height") or 0) + if width <= 0 or height <= 0: + return {"ok": False, "reason": "Windows agent did not report screen size", "events": []} + + await self.agent.command("begin_hid_capture", {}, timeout=10) + await asyncio.sleep(0.3) + ws = await self.connect_hid_websocket() + try: + await self.send_hid_mouse(ws, 0x01, 8192, 8192, 0) + await asyncio.sleep(0.2) + abs_x = 19660 + abs_y = 19660 + await self.send_hid_mouse(ws, 0x01, abs_x, abs_y, 0) + await asyncio.sleep(0.4) + payload = await self.agent.command("get_hid_events", {}, timeout=10) + base_events = payload.get("events", []) + base_move = self.last_event_of_type(base_events, "mouse_move") + + rel_dx = 32 + rel_dy = 24 + await self.send_hid_mouse(ws, 0x00, rel_dx, rel_dy, 0) + await asyncio.sleep(0.4) + payload = await self.agent.command("get_hid_events", {}, timeout=10) + events = payload.get("events", []) + finally: + await ws.close() + + target_x = self.hid_abs_to_pixel(abs_x, width) + target_y = self.hid_abs_to_pixel(abs_y, height) + abs_tolerance_x = max(100, int(width * 0.10)) + abs_tolerance_y = max(80, int(height * 0.10)) + abs_ok = bool( + base_move + and abs(int(base_move.get("x", -9999)) - target_x) <= abs_tolerance_x + and abs(int(base_move.get("y", -9999)) - target_y) <= abs_tolerance_y + ) + + relative_events = events[len(base_events) :] + rel_move = self.last_event_of_type(relative_events, "mouse_move") + rel_delta_x = int(rel_move.get("x", 0)) - int(base_move.get("x", 0)) if rel_move and base_move else 0 + rel_delta_y = int(rel_move.get("y", 0)) - int(base_move.get("y", 0)) if rel_move and base_move else 0 + rel_ok = bool( + base_move + and rel_move + and 2 <= rel_delta_x <= max(300, int(width * 0.25)) + and 2 <= rel_delta_y <= max(300, int(height * 0.25)) + ) + + return { + "ok": abs_ok and rel_ok, + "absolute_ok": abs_ok, + "relative_ok": rel_ok, + "screen": {"width": width, "height": height}, + "absolute_target": {"x": target_x, "y": target_y}, + "absolute_observed": {"x": base_move.get("x"), "y": base_move.get("y")} if base_move else None, + "relative_delta": {"x": rel_delta_x, "y": rel_delta_y}, + "events": events, + } + + async def run_hid_latency_test(self) -> None: + if not self.agent: + self.reporter.add("hid_latency", "SKIP", "Windows agent not connected") + return + trial_count = max(0, int(self.args.hid_latency_trials)) + if trial_count <= 0: + self.reporter.add("hid_latency", "SKIP", "HID latency trials disabled") + return + + key_name, usage, vk = HID_LATENCY_KEY + offset_ns, sync = await self.sync_agent_clock("hid_agent_clock_sync_rtt") + trials: list[dict[str, Any]] = [] + missing = 0 + ws = await self.connect_hid_websocket() + try: + for i in range(trial_count): + await self.agent.command("begin_hid_capture", {}, timeout=10) + await asyncio.sleep(0.1) + send_ns = time.time_ns() + await ws.send(bytes([0x01, 0x00, usage, 0x00])) + await asyncio.sleep(0.02) + await ws.send(bytes([0x01, 0x01, usage, 0x00])) + event = await self.wait_hid_key_down(vk, timeout=2.0) + if not event: + missing += 1 + continue + event_agent_ns = int(event.get("unix_nano") or 0) + event_linux_ns = event_agent_ns - offset_ns + raw_latency_ms = (event_linux_ns - send_ns) / 1_000_000 + latency_ms = max(0.0, raw_latency_ms) + trial = { + "trial": i + 1, + "key": key_name, + "latency_ms": latency_ms, + "raw_latency_ms": raw_latency_ms, + "sent_unix_nano": send_ns, + "event_agent_unix_nano": event_agent_ns, + "event_linux_unix_nano": event_linux_ns, + "clock_sync": sync, + } + trials.append(trial) + self.reporter.metric("hid_latency", round(latency_ms, 2), "ms", trial=i + 1, key=key_name) + await asyncio.sleep(0.08) + finally: + await ws.close() + + latencies = [float(t["latency_ms"]) for t in trials] + p50 = percentile(latencies, 50) + p95 = percentile(latencies, 95) + max_latency = max(latencies) if latencies else 0.0 + if latencies: + self.reporter.metric("hid_latency_p50", round(p50, 2), "ms", key=key_name) + self.reporter.metric("hid_latency_p95", round(p95, 2), "ms", key=key_name) + self.reporter.metric("hid_latency_max", round(max_latency, 2), "ms", key=key_name) + + if not trials: + status = "FAIL" + elif p95 <= self.args.hid_latency_warn_ms and missing == 0: + status = "PASS" + elif p95 <= self.args.hid_latency_fail_ms: + status = "WARN" + else: + status = "FAIL" + self.reporter.add( + "hid_latency", + status, + f"HID key latency p50 {p50:.1f} ms, p95 {p95:.1f} ms, max {max_latency:.1f} ms", + key=key_name, + trials=trials, + missing_trials=missing, + p50_ms=p50, + p95_ms=p95, + max_ms=max_latency, + warn_threshold_ms=self.args.hid_latency_warn_ms, + fail_threshold_ms=self.args.hid_latency_fail_ms, + ) + + async def wait_hid_key_down(self, vk: int, timeout: float) -> dict[str, Any] | None: + if not self.agent: + return None + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + payload = await self.agent.command("get_hid_events", {}, timeout=10) + for event in payload.get("events", []): + if event.get("type") == "key_down" and int(event.get("code") or 0) == vk: + return event + await asyncio.sleep(0.05) + return None + + async def send_hid_sequence(self, text: str) -> None: + ws = await self.connect_hid_websocket() + try: + for ch in text: + if ch not in HID_KEY_USAGE: + continue + usage, mod = HID_KEY_USAGE[ch] + await self.send_hid_key(ws, usage, mod) + finally: + await ws.close() + + async def send_hid_key(self, ws: Any, usage: int, modifier: int = 0) -> None: + await ws.send(bytes([0x01, 0x00, usage, modifier])) + await asyncio.sleep(0.02) + await ws.send(bytes([0x01, 0x01, usage, modifier])) + await asyncio.sleep(0.02) + + async def send_hid_mouse(self, ws: Any, event_type: int, x: int, y: int, value: int) -> None: + await ws.send(bytes([0x02, event_type]) + struct.pack(" int: + return int(round(value * max(span - 1, 0) / 32767)) + + @staticmethod + def last_event_of_type(events: list[dict[str, Any]], event_type: str) -> dict[str, Any] | None: + for event in reversed(events): + if event.get("type") == event_type: + return event + return None + + @staticmethod + def evaluate_hid_keyboard_events(events: list[dict[str, Any]]) -> dict[str, Any]: + chars = "".join(e.get("char", "") for e in events if e.get("type") == "char") + down_codes = {int(e.get("code") or 0) for e in events if e.get("type") == "key_down"} + up_codes = {int(e.get("code") or 0) for e in events if e.get("type") == "key_up"} + + missing_alnum_down = [label for label, _, vk in HID_ALNUM_KEYS if vk not in down_codes] + missing_alnum_up = [label for label, _, vk in HID_ALNUM_KEYS if vk not in up_codes] + missing_functions_down = [label for label, _, vk in HID_FUNCTION_KEYS if vk not in down_codes] + missing_functions_up = [label for label, _, vk in HID_FUNCTION_KEYS if vk not in up_codes] + missing_combos = [ + name + for name, _, vk, mod in HID_SAFE_COMBOS + if not any( + e.get("type") == "key_down" + and int(e.get("code") or 0) == vk + and (int(e.get("modifiers") or 0) & mod) == mod + for e in events + ) + ] + + ok = not ( + missing_alnum_down + or missing_alnum_up + or missing_functions_down + or missing_functions_up + or missing_combos + ) + return { + "ok": ok, + "chars": chars, + "event_count": len(events), + "alphanumeric_tested": len(HID_ALNUM_KEYS), + "function_keys_tested": len(HID_FUNCTION_KEYS), + "safe_combos_tested": len(HID_SAFE_COMBOS), + "missing_alphanumeric_down": missing_alnum_down, + "missing_alphanumeric_up": missing_alnum_up, + "missing_function_down": missing_functions_down, + "missing_function_up": missing_functions_up, + "missing_combos": missing_combos, + } + + async def run_msd_test(self) -> None: + if self.selected_hid_backend != "otg": + self.reporter.add("msd", "SKIP", "MSD requires OTG HID backend") + return + if not self.agent: + self.reporter.add("msd", "SKIP", "Windows agent not connected") + return + try: + status = self.api.get("/msd/status") + if not status.get("available"): + self.reporter.add("msd", "FAIL", "MSD controller is unavailable", status=status) + return + snapshot = await self.agent.command("msd_snapshot", {}, timeout=10) + known = [d["root"] for d in snapshot.get("drives", [])] + self.api.post("/msd/drive/init", {"size_mb": self.args.msd_size_mb}) + self.api.post("/msd/connect", {"mode": "drive"}) + drive = await self.agent.command("msd_wait_new", {"known": known, "timeout_ms": 60000}, timeout=70) + root = drive.get("root") + verify = await self.agent.command( + "msd_write_read", + {"root": root, "filename": f"okvm-msd-{self.run_id}.bin", "size_bytes": self.args.msd_probe_bytes}, + timeout=60, + ) + if "write_mib_s" in verify: + self.reporter.metric("msd_write_mib_s", round(float(verify["write_mib_s"]), 2), "MiB/s") + if "read_mib_s" in verify: + self.reporter.metric("msd_read_mib_s", round(float(verify["read_mib_s"]), 2), "MiB/s") + if "cached_read_mib_s" in verify: + self.reporter.metric("msd_cached_read_mib_s", round(float(verify["cached_read_mib_s"]), 2), "MiB/s") + self.api.post("/msd/disconnect", {}) + await self.agent.command("msd_wait_removed", {"root": root, "timeout_ms": 60000}, timeout=70) + self.reporter.add("msd", "PASS", "Windows detected virtual drive and read/write verification passed", drive=drive, verify=verify) + except Exception as exc: + try: + self.api.post("/msd/disconnect", {}) + except Exception: + pass + status = "SKIP" if is_msd_environment_error(str(exc)) else "FAIL" + self.reporter.add("msd", status, str(exc)) + + def run_atx_api_test(self) -> None: + try: + status = self.api.get("/atx/status") + config = self.api.get("/config/atx") + wol = self.api.post("/atx/wol", {"mac_address": self.args.wol_mac}) + self.reporter.add("atx_api", "PASS", "ATX status/config/WOL APIs responded", status=status, config=config, wol=wol) + except Exception as exc: + self.reporter.add("atx_api", "FAIL", str(exc)) + + def collect_logs(self) -> None: + if not self.ssh_password: + return + try: + ssh = SSHRunner(self.args.target, self.args.ssh_user, self.ssh_password, self.args.ssh_port) + code, out, err = ssh.run("journalctl -u one-kvm -n 300 --no-pager || true", timeout=30) + path = self.reporter.report_dir / "evidence" / "journalctl-one-kvm-tail.txt" + path.write_text(out + err, encoding="utf-8") + self.reporter.add_evidence("日志", "one-kvm journal tail", path) + self.reporter.add("log_collection", "PASS", "collected one-kvm journal tail") + except Exception as exc: + self.reporter.add("log_collection", "WARN", str(exc)) + + +VIDEO_SCREENSHOT_READY_JS = r""" +() => { + const text = (document.body?.innerText || '').toLowerCase(); + if (text.includes('connection failed') || text.includes('operation failed')) { + return true; + } + if (text.includes('waiting for first frame') || text.includes('webrtc connected') || text.includes('please wait')) { + return false; + } + const media = Array.from(document.querySelectorAll('video, canvas, img')).filter((el) => { + const r = el.getBoundingClientRect(); + return r.width >= 320 && r.height >= 180 && r.bottom > 120; + }); + return media.length > 0; +} +""" + + +WEBRTC_MEASURE_JS = r""" +async ({seconds}) => { + const api = async (path, opts = {}) => { + const response = await fetch('/api' + path, { + credentials: 'include', + headers: {'Content-Type': 'application/json', ...(opts.headers || {})}, + ...opts + }); + const data = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(path + ' HTTP ' + response.status + ': ' + JSON.stringify(data)); + return data; + }; + + const ice = await api('/webrtc/ice-servers'); + const pc = new RTCPeerConnection({ + iceServers: (ice.ice_servers || []).map(s => ({urls: s.urls, username: s.username, credential: s.credential})) + }); + pc.addTransceiver('video', {direction: 'recvonly'}); + pc.addTransceiver('audio', {direction: 'recvonly'}); + pc.createDataChannel('hid', {ordered: true, maxRetransmits: 3}); + + let sessionId = null; + const pending = []; + pc.onicecandidate = (event) => { + if (!event.candidate) return; + const item = { + candidate: event.candidate.candidate, + sdpMid: event.candidate.sdpMid, + sdpMLineIndex: event.candidate.sdpMLineIndex, + usernameFragment: event.candidate.usernameFragment + }; + if (sessionId) { + api('/webrtc/ice', {method: 'POST', body: JSON.stringify({session_id: sessionId, candidate: item})}).catch(() => {}); + } else { + pending.push(item); + } + }; + + const offer = await pc.createOffer(); + await pc.setLocalDescription(offer); + const answer = await api('/webrtc/offer', {method: 'POST', body: JSON.stringify({sdp: offer.sdp})}); + if (answer.success === false) { + throw new Error('webrtc_offer_failed: ' + (answer.message || JSON.stringify(answer))); + } + if (typeof answer.sdp !== 'string' || !answer.sdp.trim().startsWith('v=')) { + throw new Error('webrtc_offer_invalid_sdp: ' + JSON.stringify(answer).slice(0, 500)); + } + sessionId = answer.session_id; + await pc.setRemoteDescription({type: 'answer', sdp: answer.sdp}); + for (const c of answer.ice_candidates || []) { + try { await pc.addIceCandidate(c); } catch {} + } + for (const c of pending.splice(0)) { + await api('/webrtc/ice', {method: 'POST', body: JSON.stringify({session_id: sessionId, candidate: c})}).catch(() => {}); + } + + const waitStart = performance.now(); + while (pc.connectionState !== 'connected' && performance.now() - waitStart < 12000) { + if (pc.connectionState === 'failed' || pc.connectionState === 'closed') break; + await new Promise(r => setTimeout(r, 100)); + } + + const samples = []; + const endAt = performance.now() + seconds * 1000; + while (performance.now() < endAt) { + const report = await pc.getStats(); + let sample = {fps: 0, framesDecoded: 0, framesDropped: 0, rtt: 0, jitter: 0, bytes: 0}; + report.forEach(stat => { + if (stat.type === 'inbound-rtp' && stat.kind === 'video') { + sample.fps = stat.framesPerSecond || 0; + sample.framesDecoded = stat.framesDecoded || 0; + sample.framesDropped = stat.framesDropped || 0; + sample.jitter = stat.jitter || 0; + sample.bytes = stat.bytesReceived || 0; + } + if (stat.type === 'candidate-pair' && (stat.nominated || stat.selected)) { + sample.rtt = stat.currentRoundTripTime || 0; + } + }); + samples.push(sample); + await new Promise(r => setTimeout(r, 1000)); + } + const fpsValues = samples.map(s => s.fps).filter(v => v > 0); + const avgFps = fpsValues.length ? fpsValues.reduce((a, b) => a + b, 0) / fpsValues.length : 0; + const maxRtt = Math.max(0, ...samples.map(s => s.rtt || 0)); + const maxJitter = Math.max(0, ...samples.map(s => s.jitter || 0)); + const last = samples[samples.length - 1] || {}; + try { await api('/webrtc/close', {method: 'POST', body: JSON.stringify({session_id: sessionId})}); } catch {} + pc.close(); + return { + connected: pc.connectionState === 'connected' || samples.length > 0, + avgFps, + maxRtt, + maxJitter, + framesDecoded: last.framesDecoded || 0, + framesDropped: last.framesDropped || 0, + bytesReceived: last.bytes || 0, + samples + }; +} +""" + + +WEBRTC_LATENCY_SETUP_JS = r""" +async ({timeoutMs}) => { + const api = async (path, opts = {}) => { + const response = await fetch('/api' + path, { + credentials: 'include', + headers: {'Content-Type': 'application/json', ...(opts.headers || {})}, + ...opts + }); + const data = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(path + ' HTTP ' + response.status + ': ' + JSON.stringify(data)); + return data; + }; + + if (window.__okvmLatency) { + try { await api('/webrtc/close', {method: 'POST', body: JSON.stringify({session_id: window.__okvmLatency.sessionId})}); } catch {} + try { window.__okvmLatency.pc.close(); } catch {} + window.__okvmLatency = null; + } + + const ice = await api('/webrtc/ice-servers'); + const pc = new RTCPeerConnection({ + iceServers: (ice.ice_servers || []).map(s => ({urls: s.urls, username: s.username, credential: s.credential})) + }); + const media = new MediaStream(); + const video = document.createElement('video'); + video.muted = true; + video.autoplay = true; + video.playsInline = true; + video.style.cssText = 'position:fixed;left:-10000px;top:-10000px;width:640px;height:360px;'; + video.srcObject = media; + document.body.appendChild(video); + pc.ontrack = (event) => { + if (event.track && event.track.kind === 'video') { + media.addTrack(event.track); + video.play().catch(() => {}); + } + }; + pc.addTransceiver('video', {direction: 'recvonly'}); + pc.addTransceiver('audio', {direction: 'recvonly'}); + pc.createDataChannel('hid', {ordered: true, maxRetransmits: 3}); + + let sessionId = null; + const pending = []; + pc.onicecandidate = (event) => { + if (!event.candidate) return; + const item = { + candidate: event.candidate.candidate, + sdpMid: event.candidate.sdpMid, + sdpMLineIndex: event.candidate.sdpMLineIndex, + usernameFragment: event.candidate.usernameFragment + }; + if (sessionId) { + api('/webrtc/ice', {method: 'POST', body: JSON.stringify({session_id: sessionId, candidate: item})}).catch(() => {}); + } else { + pending.push(item); + } + }; + + const offer = await pc.createOffer(); + await pc.setLocalDescription(offer); + const answer = await api('/webrtc/offer', {method: 'POST', body: JSON.stringify({sdp: offer.sdp})}); + if (answer.success === false) { + throw new Error('webrtc_offer_failed: ' + (answer.message || JSON.stringify(answer))); + } + if (typeof answer.sdp !== 'string' || !answer.sdp.trim().startsWith('v=')) { + throw new Error('webrtc_offer_invalid_sdp: ' + JSON.stringify(answer).slice(0, 500)); + } + sessionId = answer.session_id; + await pc.setRemoteDescription({type: 'answer', sdp: answer.sdp}); + for (const c of answer.ice_candidates || []) { + try { await pc.addIceCandidate(c); } catch {} + } + for (const c of pending.splice(0)) { + await api('/webrtc/ice', {method: 'POST', body: JSON.stringify({session_id: sessionId, candidate: c})}).catch(() => {}); + } + + const deadline = performance.now() + timeoutMs; + while (performance.now() < deadline) { + if ((pc.connectionState === 'failed') || (pc.connectionState === 'closed')) break; + if ((pc.connectionState === 'connected' || pc.iceConnectionState === 'connected') && video.videoWidth > 0 && video.readyState >= 2) break; + await new Promise(r => setTimeout(r, 50)); + } + + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d', {willReadFrequently: true}); + window.__okvmLatency = {pc, sessionId, video, canvas, ctx}; + return { + connected: pc.connectionState === 'connected' || pc.iceConnectionState === 'connected' || video.videoWidth > 0, + connectionState: pc.connectionState, + iceConnectionState: pc.iceConnectionState, + videoWidth: video.videoWidth || 0, + videoHeight: video.videoHeight || 0, + readyState: video.readyState + }; +} +""" + + +WEBRTC_COLOR_DETECT_JS = r""" +async ({targetRgb, timeoutMs, threshold}) => { + const state = window.__okvmLatency; + if (!state || !state.video || !state.ctx) { + throw new Error('WebRTC latency detector is not initialized'); + } + const video = state.video; + const canvas = state.canvas; + const ctx = state.ctx; + const outW = 64; + const outH = 64; + canvas.width = outW; + canvas.height = outH; + const deadline = performance.now() + timeoutMs; + let framesSeen = 0; + let best = null; + + const sample = () => { + const width = video.videoWidth || 0; + const height = video.videoHeight || 0; + if (width <= 0 || height <= 0 || video.readyState < 2) return null; + const sx = Math.max(0, Math.floor(width * 0.35)); + const sy = Math.max(0, Math.floor(height * 0.35)); + const sw = Math.max(1, Math.floor(width * 0.30)); + const sh = Math.max(1, Math.floor(height * 0.30)); + ctx.drawImage(video, sx, sy, sw, sh, 0, 0, outW, outH); + const data = ctx.getImageData(0, 0, outW, outH).data; + let r = 0, g = 0, b = 0; + const pixels = outW * outH; + for (let i = 0; i < data.length; i += 4) { + r += data[i]; + g += data[i + 1]; + b += data[i + 2]; + } + const mean = [r / pixels, g / pixels, b / pixels]; + const errors = [ + Math.abs(mean[0] - targetRgb[0]), + Math.abs(mean[1] - targetRgb[1]), + Math.abs(mean[2] - targetRgb[2]) + ]; + return { + mean_rgb: mean.map(v => Math.round(v * 100) / 100), + mean_abs_error: (errors[0] + errors[1] + errors[2]) / 3, + max_abs_error: Math.max(...errors), + width, + height + }; + }; + + while (performance.now() < deadline) { + const stats = sample(); + if (stats) { + framesSeen += 1; + stats.frames_seen = framesSeen; + if (!best || stats.mean_abs_error < best.mean_abs_error) best = {...stats}; + if (stats.mean_abs_error <= threshold) { + stats.target_detected = true; + stats.wall_ns = Math.round((performance.timeOrigin + performance.now()) * 1000000); + return stats; + } + } + await new Promise(r => setTimeout(r, 16)); + } + const out = best || {mean_rgb: [0, 0, 0], mean_abs_error: 999, max_abs_error: 999, width: 0, height: 0}; + out.target_detected = false; + out.frames_seen = framesSeen; + out.wall_ns = Math.round((performance.timeOrigin + performance.now()) * 1000000); + return out; +} +""" + + +WEBRTC_LATENCY_CLOSE_JS = r""" +async () => { + const state = window.__okvmLatency; + if (!state) return true; + const api = async (path, opts = {}) => { + const response = await fetch('/api' + path, { + credentials: 'include', + headers: {'Content-Type': 'application/json', ...(opts.headers || {})}, + ...opts + }); + return response.json().catch(() => ({})); + }; + try { await api('/webrtc/close', {method: 'POST', body: JSON.stringify({session_id: state.sessionId})}); } catch {} + try { state.pc.close(); } catch {} + try { state.video.remove(); } catch {} + window.__okvmLatency = null; + return true; +} +""" + + +def local_ip_for(target: str) -> str: + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.connect((target, 80)) + return sock.getsockname()[0] + except Exception: + return "127.0.0.1" + finally: + try: + sock.close() + except Exception: + pass + + +def is_playwright_dependency_error(text: str) -> bool: + return any( + marker in text + for marker in ( + "host system is missing dependencies", + "missing libraries", + "error while loading shared libraries", + "cannot open shared object file", + ) + ) + + +def is_playwright_runtime_error(text: str) -> bool: + return ( + "playwright install" in text + or "playwright is required" in text + or "executable doesn't exist" in text + or "browser distribution" in text + or "is not found at" in text + or "not found on your system" in text + or is_playwright_dependency_error(text) + ) + + +def is_webrtc_codec_unsupported_error(text: str) -> bool: + lower = text.lower() + return any( + marker in lower + for marker in ( + "webrtc_offer_failed", + "webrtc_offer_invalid_sdp", + "failed to parse sessiondescription", + "failed to execute 'setremotedescription'", + "unsupported", + "not supported", + "codec", + ) + ) + + +def is_msd_environment_error(text: str) -> bool: + lower = text.lower() + return any( + marker in lower + for marker in ( + "resources not initialized", + "resource not found", + "boot.img not found", + "core.img not found", + "ventoy.disk.img not found", + "ventoy resources", + ) + ) + + +def default_ventoy_resources_dir() -> Path: + return Path(__file__).resolve().parents[2] / "libs" / "ventoy-img-rs" / "resources" + + +def shell_quote(value: str) -> str: + return value.replace("'", "'\"'\"'") + + +def shell_arg(value: str) -> str: + return "'" + shell_quote(value) + "'" + + +def safe_filename(value: str) -> str: + return re.sub(r"[^A-Za-z0-9_.-]+", "_", value).strip("._") or "evidence" + + +def chromium_launch_args() -> list[str]: + return list(CHROMIUM_WINDOWS_ARGS) + + +def is_windows_controller() -> bool: + return sys.platform.startswith("win") + + +def latency_stats(values: list[float]) -> dict[str, Any]: + if not values: + return {"samples": 0} + return { + "samples": len(values), + "p50_ms": percentile(values, 50), + "p95_ms": percentile(values, 95), + "max_ms": max(values), + "min_ms": min(values), + } + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="One-KVM automated acceptance test controller") + sub = parser.add_subparsers(dest="command", required=True) + run = sub.add_parser("run", help="run acceptance test") + run.add_argument("--target", required=True, help="One-KVM target IP") + run.add_argument("--http-port", type=int, default=8080) + run.add_argument("--ssh-user", default="root") + run.add_argument("--ssh-port", type=int, default=22) + run.add_argument("--ssh-password", default=None) + run.add_argument("--ssh-password-prompt", action="store_true") + run.add_argument("--data-dir", default="/etc/one-kvm") + run.add_argument("--reset", action="store_true", help="backup and remove One-KVM database before testing") + run.add_argument("--web-user", default="okvmtest") + run.add_argument("--web-password", default="okvmtest1234") + run.add_argument("--run-id", default=None) + run.add_argument("--report-dir", default="reports") + run.add_argument("--health-timeout", type=int, default=60) + run.add_argument("--network-latency-samples", type=int, default=7, help="controller-to-target network latency samples collected during setup") + run.add_argument("--network-latency-timeout", type=float, default=3.0, help="per-sample TCP/HTTP latency timeout in seconds") + run.add_argument("--sample-seconds", type=int, default=30) + run.add_argument("--jpeg-quality", type=int, default=80) + run.add_argument("--mjpeg-motion-fps", type=int, default=60, help="Windows agent dynamic source fps used during MJPEG/HTTP tests") + run.add_argument("--agent-host", default=None, help="Windows agent IP/hostname; omit to skip Windows-side checks") + run.add_argument("--agent-port", type=int, default=8765) + run.add_argument("--agent-timeout", type=int, default=180) + run.add_argument("--msd-size-mb", type=int, default=256) + run.add_argument("--msd-probe-bytes", type=int, default=1024 * 1024) + run.add_argument("--ventoy-resources-dir", default=None, help="local Ventoy resource directory; defaults to repo libs/ventoy-img-rs/resources") + run.add_argument("--no-ventoy-sync", action="store_true", help="do not copy Ventoy resources to the target before MSD testing") + run.add_argument("--no-msd-restart-after-enable", action="store_true", help="do not restart One-KVM after enabling MSD") + run.add_argument("--strict-performance", action="store_true", help="fail video results below the expected FPS threshold; default only requires fps > 0") + run.add_argument("--no-color", action="store_true", help="disable colored terminal output") + run.add_argument("--no-screenshots", action="store_true", help="skip Playwright webpage screenshots") + run.add_argument("--screenshot-wait-ms", type=int, default=6000, help="wait up to this long for video page screenshots to reach a stable state") + run.add_argument("--hid-latency-trials", type=int, default=5) + run.add_argument("--hid-latency-warn-ms", type=float, default=80.0) + run.add_argument("--hid-latency-fail-ms", type=float, default=200.0) + run.add_argument("--no-hdmi-tests", action="store_true", help="skip HDMI identity/color and video visual latency tests") + run.add_argument("--hdmi-settle-ms", type=int, default=600) + run.add_argument("--hdmi-capture-timeout", type=float, default=8.0) + run.add_argument("--hdmi-match-frames", type=int, default=1, help="consecutive matching MJPEG frames required for HDMI color detection") + run.add_argument("--hdmi-color-warn", type=float, default=30.0) + run.add_argument("--hdmi-color-fail", type=float, default=60.0) + run.add_argument("--video-latency-trials", "--hdmi-latency-trials", dest="hdmi_latency_trials", type=int, default=5) + run.add_argument("--video-latency-delay-ms", "--hdmi-latency-delay-ms", dest="hdmi_latency_delay_ms", type=int, default=2000) + run.add_argument("--video-latency-timeout-ms", "--hdmi-latency-timeout-ms", dest="hdmi_latency_timeout_ms", type=int, default=3000) + run.add_argument("--video-latency-warn-ms", "--hdmi-latency-warn-ms", dest="hdmi_latency_warn_ms", type=float, default=3000.0, help=argparse.SUPPRESS) + run.add_argument("--video-latency-fail-ms", "--hdmi-latency-fail-ms", dest="hdmi_latency_fail_ms", type=float, default=3000.0) + run.add_argument("--wol-mac", default="02:00:00:00:00:01") + return parser + + +async def async_main(argv: list[str]) -> int: + args = build_parser().parse_args(argv) + if args.command == "run": + if not is_windows_controller(): + print("okvm_testctl.py run is supported only on native Windows. Run it from Windows PowerShell, not WSL/Linux.", file=sys.stderr) + return 2 + runner = AcceptanceRunner(args) + return await runner.run() + raise AssertionError(args.command) + + +def main() -> None: + try: + raise SystemExit(asyncio.run(async_main(sys.argv[1:]))) + except KeyboardInterrupt: + raise SystemExit(130) + + +if __name__ == "__main__": + main() diff --git a/test/okvm-test/requirements.txt b/test/okvm-test/requirements.txt new file mode 100644 index 00000000..b287302c --- /dev/null +++ b/test/okvm-test/requirements.txt @@ -0,0 +1,5 @@ +httpx>=0.27 +paramiko>=3.4 +websockets>=12 +playwright>=1.45 +Pillow>=10 From 618599266e8f4c6f41381eeef2c02522629e8ede Mon Sep 17 00:00:00 2001 From: mofeng-git Date: Sun, 5 Jul 2026 22:01:33 +0800 Subject: [PATCH 05/16] =?UTF-8?q?fix=EF=BC=9A=E6=81=A2=E5=A4=8D=E9=94=99?= =?UTF-8?q?=E8=AF=AF=E6=B6=88=E5=A4=B1=E7=9A=84=20redfish=20=E5=85=A5?= =?UTF-8?q?=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/i18n/en-US.ts | 9 +- web/src/i18n/zh-CN.ts | 9 +- web/src/views/SettingsView.vue | 169 ++++++++++++++++++++++++++------- 3 files changed, 149 insertions(+), 38 deletions(-) diff --git a/web/src/i18n/en-US.ts b/web/src/i18n/en-US.ts index 0c6552d9..c0001cf9 100644 --- a/web/src/i18n/en-US.ts +++ b/web/src/i18n/en-US.ts @@ -521,7 +521,7 @@ export default { environmentSubtitle: 'System runtime environment and USB device maintenance', aboutSubtitle: 'Online upgrade, version info and hardware overview', extTtydSubtitle: 'Open a host Shell terminal in the browser', - thirdPartyAccessSubtitle: 'Configure external RustDesk, VNC, and RTSP access', + thirdPartyAccessSubtitle: 'Configure external RustDesk, VNC, RTSP, and Redfish access', extRemoteAccessSubtitle: 'Remote access through NAT-traversal services', extFrpcSubtitle: 'NAT traversal through the FRP client', aboutDesc: 'Open and Lightweight IP-KVM Solution', @@ -573,10 +573,13 @@ export default { httpsEnabledDesc: 'Serve over an encrypted connection. A self-signed certificate is generated automatically if none is provided.', portConfig: 'Port & Protocol', portConfigDesc: 'The service listens on a single port at a time, determined by the HTTPS toggle', - redfishTitle: 'Redfish API', + redfishTitle: 'Redfish', redfishDesc: 'DMTF Redfish standard management interface', redfishEnabled: 'Enable Redfish API', redfishEnabledDesc: 'When enabled, the standard Redfish management interface is available at /redfish/v1/', + redfishEndpoint: 'Redfish Endpoint', + copyRedfishUrl: 'Copy Redfish URL', + openRedfishEndpoint: 'Open Redfish endpoint', httpPortReserved: 'HTTP port (reserved)', httpsPortReserved: 'HTTPS port (reserved)', portActive: 'Active', @@ -1075,6 +1078,7 @@ export default { title: 'RTSP Streaming', desc: 'Configure RTSP video output service (H.264/H.265)', bind: 'Bind Address', + bindHint: 'Enter an IPv4 or IPv6 address; configure the port in the port field.', port: 'Port', path: 'Stream Path', pathPlaceholder: 'live', @@ -1092,6 +1096,7 @@ export default { title: 'VNC Remote', desc: 'Access via TigerVNC client', bind: 'Bind Address', + bindHint: 'Enter an IPv4 or IPv6 address; configure the port in the port field.', port: 'Port', encoding: 'Video Encoding', encodingTightJpeg: 'Tight JPEG', diff --git a/web/src/i18n/zh-CN.ts b/web/src/i18n/zh-CN.ts index 83f7d690..de7ab67e 100644 --- a/web/src/i18n/zh-CN.ts +++ b/web/src/i18n/zh-CN.ts @@ -520,7 +520,7 @@ export default { environmentSubtitle: '系统级运行环境与 USB 设备维护', aboutSubtitle: '在线升级、版本信息与设备硬件概览', extTtydSubtitle: '在浏览器中打开本机 Shell 终端', - thirdPartyAccessSubtitle: '集中配置 RustDesk、VNC 与 RTSP 外部接入', + thirdPartyAccessSubtitle: '集中配置 RustDesk、VNC、RTSP 与 Redfish 外部接入', extRemoteAccessSubtitle: '通过内网穿透服务实现远程访问', extFrpcSubtitle: '通过 FRP 客户端实现内网穿透', aboutDesc: '开放轻量的 IP-KVM 解决方案', @@ -572,10 +572,13 @@ export default { httpsEnabledDesc: '使用加密连接对外提供服务,未配置证书时自动生成自签名证书', portConfig: '端口与协议', portConfigDesc: '服务一次仅监听一个端口,由 HTTPS 开关决定生效端口', - redfishTitle: 'Redfish API', + redfishTitle: 'Redfish', redfishDesc: 'DMTF Redfish 标准管理接口', redfishEnabled: '启用 Redfish API', redfishEnabledDesc: '开启后可通过 /redfish/v1/ 访问标准 Redfish 管理接口', + redfishEndpoint: 'Redfish 入口', + copyRedfishUrl: '复制 Redfish 地址', + openRedfishEndpoint: '打开 Redfish 入口', httpPortReserved: 'HTTP 端口(备用)', httpsPortReserved: 'HTTPS 端口(备用)', portActive: '当前生效', @@ -1074,6 +1077,7 @@ export default { title: 'RTSP 视频流', desc: '配置 RTSP 推流服务(H.264/H.265)', bind: '监听地址', + bindHint: '填写 IPv4 或 IPv6 地址;端口请在端口字段配置。', port: '端口', path: '流路径', pathPlaceholder: 'live', @@ -1091,6 +1095,7 @@ export default { title: 'VNC 远程', desc: '通过 TigerVNC 客户端访问', bind: '监听地址', + bindHint: '填写 IPv4 或 IPv6 地址;端口请在端口字段配置。', port: '端口', encoding: '视频编码', encodingTightJpeg: 'Tight JPEG', diff --git a/web/src/views/SettingsView.vue b/web/src/views/SettingsView.vue index afa7b79a..a086353a 100644 --- a/web/src/views/SettingsView.vue +++ b/web/src/views/SettingsView.vue @@ -224,6 +224,7 @@ function normalizeSettingsSection(value: unknown): SettingsSectionId | null { if (typeof value !== 'string') return null if (value === 'access-control') return 'account' if (value === 'ext-frpc') return 'ext-remote-access' + if (value === 'redfish') return 'third-party-access' if (value === 'ext-rustdesk' || value === 'ext-vnc' || value === 'ext-rtsp') return 'third-party-access' return isSettingsSectionId(value) ? value : null } @@ -242,10 +243,7 @@ async function loadSectionData(section: SettingsSectionId) { await loadAuthConfig() return case 'network': - await Promise.all([ - loadWebServerConfig(), - loadRedfishConfig(), - ]) + await loadWebServerConfig() return case 'video': await Promise.all([ @@ -277,6 +275,8 @@ async function loadSectionData(section: SettingsSectionId) { return case 'third-party-access': await Promise.all([ + loadWebServerConfig(), + loadRedfishConfig(), loadRustdeskConfig(), loadRustdeskPassword(), loadRtspConfig(), @@ -520,9 +520,12 @@ const previewAccessUrl = computed(() => { const host = firstAddr.includes(':') ? `[${firstAddr}]` : firstAddr return `${scheme}://${host}:${port}` }) +const redfishAccessUrl = computed(() => `${previewAccessUrl.value}/redfish/v1/`) const previewUrlCopied = ref(false) +const redfishUrlCopied = ref(false) let previewUrlCopiedTimer: ReturnType | null = null +let redfishUrlCopiedTimer: ReturnType | null = null async function copyPreviewUrl() { const ok = await clipboardCopy(previewAccessUrl.value) @@ -538,6 +541,20 @@ function openPreviewUrl() { window.open(previewAccessUrl.value, '_blank', 'noopener,noreferrer') } +async function copyRedfishUrl() { + const ok = await clipboardCopy(redfishAccessUrl.value) + if (!ok) return + redfishUrlCopied.value = true + if (redfishUrlCopiedTimer) clearTimeout(redfishUrlCopiedTimer) + redfishUrlCopiedTimer = setTimeout(() => { + redfishUrlCopied.value = false + }, 1500) +} + +function openRedfishUrl() { + window.open(redfishAccessUrl.value, '_blank', 'noopener,noreferrer') +} + interface DeviceConfig { video: Array<{ path: string @@ -3890,34 +3907,6 @@ watch(isWindows, () => { - - - - - {{ t('settings.redfishTitle') }} - {{ t('settings.redfishDesc') }} - - -
-
- -

{{ t('settings.redfishEnabledDesc') }}

-
- -
-
- -

- - {{ t('settings.restartRequiredHint') }} -

- -
-
@@ -4726,7 +4715,10 @@ watch(isWindows, () => {
- +
+ +

{{ t('extensions.rtsp.bindHint') }}

+
@@ -4856,7 +4848,10 @@ watch(isWindows, () => {
- +
+ +

{{ t('extensions.vnc.bindHint') }}

+
@@ -5112,6 +5107,112 @@ watch(isWindows, () => {
+ +
+ +
+ +
+

{{ t('settings.autoRestarting') }}

+

+ {{ webServerConfig.https_enabled + ? t('settings.autoRestartingHttpsDesc', { sec: autoRestartCountdown }) + : t('settings.autoRestartingDesc') }} +

+
+ + {{ autoRestartCountdown }} + +
+ + +
+
+ +
+

{{ t('settings.httpsManualRedirectTitle') }}

+

{{ t('settings.httpsManualRedirectDesc') }}

+
+
+ + + {{ autoRestartManualUrl }} + +
+ + +
+

{{ t('settings.autoRestartFailed') }}

+ +
+ + + + {{ t('settings.redfishTitle') }} + {{ t('settings.redfishDesc') }} + + +
+
+ +

{{ t('settings.redfishEnabledDesc') }}

+
+ +
+
+

{{ t('settings.redfishEndpoint') }}

+
+ {{ redfishAccessUrl }} + + +
+
+
+ +

+ + {{ t('settings.restartRequiredHint') }} +

+ +
+
+
+
From f6e97a06f59c5eaadd681b88cfb9078e4f5a1b65 Mon Sep 17 00:00:00 2001 From: mofeng-git Date: Mon, 6 Jul 2026 20:51:58 +0800 Subject: [PATCH 06/16] =?UTF-8?q?del:=20=E5=88=A0=E9=99=A4=20MJPEG=20?= =?UTF-8?q?=E8=BE=93=E5=85=A5=E6=A0=BC=E5=BC=8F=E8=87=AA=E5=8A=A8=E5=88=87?= =?UTF-8?q?=E6=8D=A2=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/video/stream_manager.rs | 28 ---------------------------- 1 file changed, 28 deletions(-) diff --git a/src/video/stream_manager.rs b/src/video/stream_manager.rs index 3ee22698..d591cfc8 100644 --- a/src/video/stream_manager.rs +++ b/src/video/stream_manager.rs @@ -37,7 +37,6 @@ use crate::events::{EventBus, SystemEvent, VideoDeviceInfo}; use crate::hid::HidController; use crate::stream::MjpegStreamHandler; use crate::video::codec_constraints::StreamCodecConstraints; -use crate::video::device::is_csi_hdmi_bridge; use crate::video::format::{PixelFormat, Resolution}; use crate::video::streamer::{Streamer, StreamerState, StreamerStats}; use crate::video::traits::VideoOutput; @@ -439,33 +438,6 @@ impl VideoStreamManager { StreamMode::Mjpeg => { info!("Starting MJPEG streaming"); - // Auto-switch to MJPEG format if device supports it - if let Some(device) = self.streamer.current_device().await { - let (current_format, resolution, fps) = - self.streamer.current_video_config().await; - let available_formats: Vec = - device.formats.iter().map(|f| f.format).collect(); - - // If current format is not MJPEG and device supports MJPEG, switch to it - if !is_csi_hdmi_bridge(&device) - && current_format != PixelFormat::Mjpeg - && available_formats.contains(&PixelFormat::Mjpeg) - { - info!("Auto-switching to MJPEG format for MJPEG mode"); - let device_path = device.path.to_string_lossy().to_string(); - if let Err(e) = self - .streamer - .apply_video_config(&device_path, PixelFormat::Mjpeg, resolution, fps) - .await - { - warn!( - "Failed to auto-switch to MJPEG format: {}, keeping current format", - e - ); - } - } - } - if let Err(e) = self.streamer.start().await { error!("Failed to start MJPEG streamer: {}", e); return Err(e); From e60152d38b50db8ec67bfde40827429967a8b1a3 Mon Sep 17 00:00:00 2001 From: mofeng-git Date: Tue, 7 Jul 2026 00:21:54 +0800 Subject: [PATCH 07/16] =?UTF-8?q?feat:=20=E5=AE=8C=E5=96=84=20ATX=20?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=E9=80=BB=E8=BE=91=E4=B8=8E=E6=8E=A7=E4=BB=B6?= =?UTF-8?q?=E6=A0=B7=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/atx/controller.rs | 251 ++++++++++++---- src/atx/disabled_led.rs | 10 +- src/atx/led.rs | 72 +++-- src/atx/mod.rs | 7 +- src/atx/types.rs | 134 ++++++--- src/config/schema/atx.rs | 58 +++- src/config/schema/mod.rs | 1 + src/events/types.rs | 1 + src/platform/defaults.rs | 31 +- src/state.rs | 21 +- src/web/handlers/atx_api.rs | 28 +- src/web/handlers/config/atx.rs | 34 +-- src/web/handlers/config/types.rs | 460 +++++++++++++++-------------- src/web/handlers/system.rs | 5 +- web/src/api/index.ts | 2 + web/src/components/AtxPopover.vue | 190 ++++++------ web/src/i18n/en-US.ts | 31 +- web/src/i18n/zh-CN.ts | 31 +- web/src/stores/system.ts | 4 + web/src/types/generated.ts | 51 ++-- web/src/views/SettingsView.vue | 472 +++++++++++++++--------------- 21 files changed, 1058 insertions(+), 836 deletions(-) diff --git a/src/atx/controller.rs b/src/atx/controller.rs index e50ae2b4..6cdc8b18 100644 --- a/src/atx/controller.rs +++ b/src/atx/controller.rs @@ -8,15 +8,22 @@ use tracing::{debug, info, warn}; use super::executor::{timing, AtxKeyExecutor}; use super::led::LedSensor; -use super::types::{AtxAction, AtxKeyConfig, AtxLedConfig, AtxState, PowerStatus}; +use super::types::{ + AtxAction, AtxDriverType, AtxInputBinding, AtxKeyConfig, AtxOutputBinding, AtxState, HddStatus, + PowerStatus, +}; use crate::error::{AppError, Result}; #[derive(Debug, Clone, Default)] pub struct AtxControllerConfig { pub enabled: bool, - pub power: AtxKeyConfig, - pub reset: AtxKeyConfig, - pub led: AtxLedConfig, + pub driver: AtxDriverType, + pub device: String, + pub baud_rate: u32, + pub power: AtxOutputBinding, + pub reset: AtxOutputBinding, + pub led: AtxInputBinding, + pub hdd: AtxInputBinding, } /// Grouped together to reduce lock acquisitions @@ -25,6 +32,7 @@ struct AtxInner { power_executor: Option, reset_executor: Option, led_sensor: Option, + hdd_sensor: Option, } /// Manages ATX power control through independent executors for each action. @@ -34,14 +42,44 @@ pub struct AtxController { } impl AtxController { - fn should_share_serial_device(power: &AtxKeyConfig, reset: &AtxKeyConfig) -> bool { - power.is_configured() - && reset.is_configured() - && power.driver == super::types::AtxDriverType::Serial - && reset.driver == super::types::AtxDriverType::Serial - && !power.device.is_empty() - && power.device == reset.device - && power.baud_rate == reset.baud_rate + fn build_key_config( + config: &AtxControllerConfig, + binding: &AtxOutputBinding, + ) -> Option { + if !binding.is_configured_for(config.driver, &config.device) { + return None; + } + + let device = match config.driver { + AtxDriverType::Gpio => binding.device.clone(), + AtxDriverType::UsbRelay | AtxDriverType::Serial => config.device.clone(), + AtxDriverType::None => return None, + }; + + Some(AtxKeyConfig { + driver: config.driver, + device, + pin: binding.pin, + active_level: binding.active_level, + baud_rate: config.baud_rate, + }) + } + + fn runtime_key_configs( + config: &AtxControllerConfig, + ) -> (Option, Option) { + ( + Self::build_key_config(config, &config.power), + Self::build_key_config(config, &config.reset), + ) + } + + fn should_share_serial_device(config: &AtxControllerConfig) -> bool { + if config.driver != AtxDriverType::Serial || config.device.trim().is_empty() { + return false; + } + + config.power.enabled && config.reset.enabled } async fn init_key_executor( @@ -63,75 +101,80 @@ impl AtxController { } async fn init_components(inner: &mut AtxInner) { - if Self::should_share_serial_device(&inner.config.power, &inner.config.reset) { - match AtxKeyExecutor::open_shared_serial( - &inner.config.power.device, - inner.config.power.baud_rate, - ) { + let (power_config, reset_config) = Self::runtime_key_configs(&inner.config); + + if Self::should_share_serial_device(&inner.config) { + match AtxKeyExecutor::open_shared_serial(&inner.config.device, inner.config.baud_rate) { Ok(shared_serial) => { for (slot, warn_label, info_label, config, serial) in [ ( &mut inner.power_executor, "power", "Power", - inner.config.power.clone(), + power_config.clone(), shared_serial.clone(), ), ( &mut inner.reset_executor, "reset", "Reset", - inner.config.reset.clone(), + reset_config.clone(), shared_serial, ), ] { - let executor = - AtxKeyExecutor::new_with_shared_serial(config.clone(), serial); - *slot = - Self::init_key_executor(warn_label, info_label, config, executor).await; + if let Some(config) = config { + let executor = + AtxKeyExecutor::new_with_shared_serial(config.clone(), serial); + *slot = + Self::init_key_executor(warn_label, info_label, config, executor) + .await; + } } } Err(e) => { warn!( "Failed to open shared serial device {} for ATX power/reset: {}", - inner.config.power.device, e + inner.config.device, e ); } } } else { for (slot, warn_label, info_label, config) in [ - ( - &mut inner.power_executor, - "power", - "Power", - inner.config.power.clone(), - ), - ( - &mut inner.reset_executor, - "reset", - "Reset", - inner.config.reset.clone(), - ), + (&mut inner.power_executor, "power", "Power", power_config), + (&mut inner.reset_executor, "reset", "Reset", reset_config), ] { - if config.is_configured() { + if let Some(config) = config { let executor = AtxKeyExecutor::new(config.clone()); *slot = Self::init_key_executor(warn_label, info_label, config, executor).await; } } } - if inner.config.led.is_configured() { + if inner.config.driver == AtxDriverType::Gpio && inner.config.led.is_configured() { let mut sensor = LedSensor::new(inner.config.led.clone()); if let Err(e) = sensor.init().await { warn!("Failed to initialize LED sensor: {}", e); } else { info!( "LED sensor initialized on {} pin {}", - inner.config.led.gpio_chip, inner.config.led.gpio_pin + inner.config.led.device, inner.config.led.pin ); inner.led_sensor = Some(sensor); } } + + if inner.config.driver == AtxDriverType::Gpio && inner.config.hdd.is_configured() { + let mut sensor = LedSensor::new(inner.config.hdd.clone()); + if let Err(e) = sensor.init().await { + warn!("Failed to initialize HDD sensor: {}", e); + } else { + info!( + "HDD sensor initialized on {} pin {}", + inner.config.hdd.device, inner.config.hdd.pin + ); + inner.hdd_sensor = Some(sensor); + } + } } async fn shutdown_components(inner: &mut AtxInner) { @@ -153,6 +196,13 @@ impl AtxController { } } inner.led_sensor = None; + + if let Some(sensor) = inner.hdd_sensor.as_mut() { + if let Err(e) = sensor.shutdown().await { + warn!("Failed to shutdown HDD sensor: {}", e); + } + } + inner.hdd_sensor = None; } async fn read_power_status(sensor: Option<&LedSensor>) -> PowerStatus { @@ -169,6 +219,21 @@ impl AtxController { } } + async fn read_hdd_status(sensor: Option<&LedSensor>) -> HddStatus { + let Some(sensor) = sensor else { + return HddStatus::Unknown; + }; + + match sensor.read_active().await { + Ok(true) => HddStatus::Active, + Ok(false) => HddStatus::Inactive, + Err(e) => { + debug!("Failed to read ATX HDD sensor: {}", e); + HddStatus::Unknown + } + } + } + pub fn new(config: AtxControllerConfig) -> Self { Self { inner: RwLock::new(AtxInner { @@ -176,6 +241,7 @@ impl AtxController { power_executor: None, reset_executor: None, led_sensor: None, + hdd_sensor: None, }), } } @@ -270,13 +336,21 @@ impl AtxController { let inner = self.inner.read().await; let power_status = Self::read_power_status(inner.led_sensor.as_ref()).await; + let hdd_status = Self::read_hdd_status(inner.hdd_sensor.as_ref()).await; AtxState { available: inner.config.enabled, + driver: if inner.config.enabled { + inner.config.driver + } else { + AtxDriverType::None + }, power_configured: inner.power_executor.is_some(), reset_configured: inner.reset_executor.is_some(), power_status, led_supported: inner.led_sensor.is_some(), + hdd_status, + hdd_supported: inner.hdd_sensor.is_some(), } } } @@ -284,45 +358,96 @@ impl AtxController { #[cfg(test)] mod tests { use super::*; - use crate::atx::AtxDriverType; + use crate::atx::{AtxDriverType, AtxOutputBinding}; #[test] fn test_should_share_serial_device_true() { - let power = AtxKeyConfig { + let config = AtxControllerConfig { driver: AtxDriverType::Serial, device: "/dev/ttyUSB0".to_string(), - pin: 1, - active_level: super::super::types::ActiveLevel::High, - baud_rate: 9600, - }; - let reset = AtxKeyConfig { - driver: AtxDriverType::Serial, - device: "/dev/ttyUSB0".to_string(), - pin: 2, - active_level: super::super::types::ActiveLevel::High, baud_rate: 9600, + power: AtxOutputBinding { + enabled: true, + pin: 1, + ..Default::default() + }, + reset: AtxOutputBinding { + enabled: true, + pin: 2, + ..Default::default() + }, + ..Default::default() }; - assert!(AtxController::should_share_serial_device(&power, &reset)); + assert!(AtxController::should_share_serial_device(&config)); } #[test] - fn test_should_share_serial_device_false_on_different_baud() { - let power = AtxKeyConfig { + fn test_should_share_serial_device_false_when_reset_disabled() { + let config = AtxControllerConfig { driver: AtxDriverType::Serial, device: "/dev/ttyUSB0".to_string(), - pin: 1, - active_level: super::super::types::ActiveLevel::High, baud_rate: 9600, - }; - let reset = AtxKeyConfig { - driver: AtxDriverType::Serial, - device: "/dev/ttyUSB0".to_string(), - pin: 2, - active_level: super::super::types::ActiveLevel::High, - baud_rate: 115200, + power: AtxOutputBinding { + enabled: true, + pin: 1, + ..Default::default() + }, + reset: AtxOutputBinding { + enabled: false, + pin: 2, + ..Default::default() + }, + ..Default::default() }; - assert!(!AtxController::should_share_serial_device(&power, &reset)); + assert!(!AtxController::should_share_serial_device(&config)); + } + + #[test] + fn test_gpio_runtime_key_uses_binding_device() { + let config = AtxControllerConfig { + driver: AtxDriverType::Gpio, + device: "/dev/ignored".to_string(), + baud_rate: 9600, + power: AtxOutputBinding { + enabled: true, + device: "/dev/gpiochip1".to_string(), + pin: 4, + active_level: super::super::types::ActiveLevel::Low, + }, + ..Default::default() + }; + + let (power, reset) = AtxController::runtime_key_configs(&config); + let power = power.unwrap(); + assert!(reset.is_none()); + assert_eq!(power.driver, AtxDriverType::Gpio); + assert_eq!(power.device, "/dev/gpiochip1"); + assert_eq!(power.pin, 4); + assert_eq!(power.active_level, super::super::types::ActiveLevel::Low); + } + + #[test] + fn test_serial_runtime_key_uses_top_level_device() { + let config = AtxControllerConfig { + driver: AtxDriverType::Serial, + device: "/dev/ttyUSB0".to_string(), + baud_rate: 115200, + power: AtxOutputBinding { + enabled: true, + device: "/dev/ignored".to_string(), + pin: 3, + ..Default::default() + }, + ..Default::default() + }; + + let (power, _) = AtxController::runtime_key_configs(&config); + let power = power.unwrap(); + assert_eq!(power.driver, AtxDriverType::Serial); + assert_eq!(power.device, "/dev/ttyUSB0"); + assert_eq!(power.pin, 3); + assert_eq!(power.baud_rate, 115200); } } diff --git a/src/atx/disabled_led.rs b/src/atx/disabled_led.rs index 52ef9034..47708e12 100644 --- a/src/atx/disabled_led.rs +++ b/src/atx/disabled_led.rs @@ -1,14 +1,14 @@ #![allow(dead_code)] -use super::types::{AtxLedConfig, PowerStatus}; +use super::types::{AtxInputBinding, PowerStatus}; use crate::error::Result; pub struct LedSensor { - config: AtxLedConfig, + config: AtxInputBinding, } impl LedSensor { - pub fn new(config: AtxLedConfig) -> Self { + pub fn new(config: AtxInputBinding) -> Self { Self { config } } @@ -28,6 +28,10 @@ impl LedSensor { Ok(PowerStatus::Unknown) } + pub async fn read_active(&self) -> Result { + Ok(false) + } + pub async fn shutdown(&mut self) -> Result<()> { Ok(()) } diff --git a/src/atx/led.rs b/src/atx/led.rs index 0b4db12e..2d0c3c47 100644 --- a/src/atx/led.rs +++ b/src/atx/led.rs @@ -7,17 +7,17 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Mutex; use tracing::{debug, info}; -use super::types::{AtxLedConfig, PowerStatus}; +use super::types::{ActiveLevel, AtxInputBinding, PowerStatus}; use crate::error::{AppError, Result}; pub struct LedSensor { - config: AtxLedConfig, + config: AtxInputBinding, handle: Mutex>, initialized: AtomicBool, } impl LedSensor { - pub fn new(config: AtxLedConfig) -> Self { + pub fn new(config: AtxInputBinding) -> Self { Self { config, handle: Mutex::new(None), @@ -33,17 +33,14 @@ impl LedSensor { info!( "Initializing LED sensor on {} pin {}", - self.config.gpio_chip, self.config.gpio_pin + self.config.device, self.config.pin ); - let mut chip = Chip::new(&self.config.gpio_chip) + let mut chip = Chip::new(&self.config.device) .map_err(|e| AppError::Internal(format!("LED GPIO chip failed: {}", e)))?; - let line = chip.get_line(self.config.gpio_pin).map_err(|e| { - AppError::Internal(format!( - "LED GPIO line {} failed: {}", - self.config.gpio_pin, e - )) + let line = chip.get_line(self.config.pin).map_err(|e| { + AppError::Internal(format!("LED GPIO line {} failed: {}", self.config.pin, e)) })?; let handle = line @@ -57,9 +54,11 @@ impl LedSensor { Ok(()) } - pub async fn read(&self) -> Result { + pub async fn read_active(&self) -> Result { if !self.config.is_configured() || !self.initialized.load(Ordering::Relaxed) { - return Ok(PowerStatus::Unknown); + return Err(AppError::Internal( + "GPIO input sensor not initialized".to_string(), + )); } let guard = self.handle.lock().unwrap(); @@ -69,22 +68,31 @@ impl LedSensor { .get_value() .map_err(|e| AppError::Internal(format!("LED read failed: {}", e)))?; - let is_on = if self.config.inverted { - value == 0 - } else { - value == 1 + let active = match self.config.active_level { + ActiveLevel::High => value == 1, + ActiveLevel::Low => value == 0, }; - Ok(if is_on { - PowerStatus::On - } else { - PowerStatus::Off - }) + Ok(active) } - None => Ok(PowerStatus::Unknown), + None => Err(AppError::Internal( + "GPIO input sensor not initialized".to_string(), + )), } } + pub async fn read(&self) -> Result { + if !self.config.is_configured() || !self.initialized.load(Ordering::Relaxed) { + return Ok(PowerStatus::Unknown); + } + + Ok(if self.read_active().await? { + PowerStatus::On + } else { + PowerStatus::Off + }) + } + pub async fn shutdown(&mut self) -> Result<()> { *self.handle.lock().unwrap() = None; self.initialized.store(false, Ordering::Relaxed); @@ -105,7 +113,7 @@ mod tests { #[test] fn test_led_sensor_creation() { - let config = AtxLedConfig::default(); + let config = AtxInputBinding::default(); let sensor = LedSensor::new(config); assert!(!sensor.config.is_configured()); assert!(!sensor.initialized.load(Ordering::Relaxed)); @@ -113,11 +121,11 @@ mod tests { #[test] fn test_led_sensor_with_config() { - let config = AtxLedConfig { + let config = AtxInputBinding { enabled: true, - gpio_chip: "/dev/gpiochip0".to_string(), - gpio_pin: 7, - inverted: false, + device: "/dev/gpiochip0".to_string(), + pin: 7, + active_level: ActiveLevel::High, }; let sensor = LedSensor::new(config); assert!(sensor.config.is_configured()); @@ -126,13 +134,13 @@ mod tests { #[test] fn test_led_sensor_inverted_config() { - let config = AtxLedConfig { + let config = AtxInputBinding { enabled: true, - gpio_chip: "/dev/gpiochip0".to_string(), - gpio_pin: 7, - inverted: true, + device: "/dev/gpiochip0".to_string(), + pin: 7, + active_level: ActiveLevel::Low, }; let sensor = LedSensor::new(config); - assert!(sensor.config.inverted); + assert_eq!(sensor.config.active_level, ActiveLevel::Low); } } diff --git a/src/atx/mod.rs b/src/atx/mod.rs index a4124abf..35e4a94a 100644 --- a/src/atx/mod.rs +++ b/src/atx/mod.rs @@ -24,8 +24,8 @@ mod wol; pub use controller::{AtxController, AtxControllerConfig}; pub use executor::timing; pub use types::{ - ActiveLevel, AtxAction, AtxDevices, AtxDriverType, AtxKeyConfig, AtxLedConfig, AtxPowerRequest, - AtxState, PowerStatus, + ActiveLevel, AtxAction, AtxDevices, AtxDriverType, AtxInputBinding, AtxKeyConfig, + AtxOutputBinding, AtxPowerRequest, AtxState, HddStatus, PowerStatus, }; pub use wol::{list_wol_history, record_wol_history, send_wol}; @@ -120,7 +120,8 @@ mod tests { let _: AtxDriverType = AtxDriverType::None; let _: ActiveLevel = ActiveLevel::High; let _: AtxKeyConfig = AtxKeyConfig::default(); - let _: AtxLedConfig = AtxLedConfig::default(); + let _: AtxInputBinding = AtxInputBinding::default(); + let _: AtxOutputBinding = AtxOutputBinding::default(); let _: AtxState = AtxState::default(); let _: AtxDevices = AtxDevices::default(); } diff --git a/src/atx/types.rs b/src/atx/types.rs index ba353062..c2c28588 100644 --- a/src/atx/types.rs +++ b/src/atx/types.rs @@ -1,7 +1,6 @@ //! ATX data types and structures //! -//! Defines the configuration and state types for the flexible ATX power control system. -//! Each ATX action (power, reset) can be independently configured with different hardware. +//! Defines the configuration and state types for the ATX power control system. use serde::{Deserialize, Serialize}; use typeshare::typeshare; @@ -15,6 +14,15 @@ pub enum PowerStatus { Unknown, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum HddStatus { + Active, + Inactive, + #[default] + Unknown, +} + #[typeshare] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[serde(rename_all = "lowercase")] @@ -36,6 +44,56 @@ pub enum ActiveLevel { } #[typeshare] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(default)] +pub struct AtxOutputBinding { + pub enabled: bool, + pub device: String, + pub pin: u32, + pub active_level: ActiveLevel, +} + +impl Default for AtxOutputBinding { + fn default() -> Self { + Self { + enabled: false, + device: String::new(), + pin: 1, + active_level: ActiveLevel::High, + } + } +} + +impl AtxOutputBinding { + pub fn is_configured_for(&self, driver: AtxDriverType, top_level_device: &str) -> bool { + if !self.enabled || driver == AtxDriverType::None { + return false; + } + + match driver { + AtxDriverType::Gpio => !self.device.trim().is_empty(), + AtxDriverType::UsbRelay | AtxDriverType::Serial => !top_level_device.trim().is_empty(), + AtxDriverType::None => false, + } + } +} + +#[typeshare] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +#[serde(default)] +pub struct AtxInputBinding { + pub enabled: bool, + pub device: String, + pub pin: u32, + pub active_level: ActiveLevel, +} + +impl AtxInputBinding { + pub fn is_configured(&self) -> bool { + self.enabled && !self.device.trim().is_empty() + } +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(default)] pub struct AtxKeyConfig { @@ -64,29 +122,16 @@ impl AtxKeyConfig { } } -#[typeshare] -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] -#[serde(default)] -pub struct AtxLedConfig { - pub enabled: bool, - pub gpio_chip: String, - pub gpio_pin: u32, - pub inverted: bool, -} - -impl AtxLedConfig { - pub fn is_configured(&self) -> bool { - self.enabled && !self.gpio_chip.is_empty() - } -} - #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct AtxState { pub available: bool, + pub driver: AtxDriverType, pub power_configured: bool, pub reset_configured: bool, pub power_status: PowerStatus, pub led_supported: bool, + pub hdd_status: HddStatus, + pub hdd_supported: bool, } #[derive(Debug, Clone, Deserialize)] @@ -139,6 +184,29 @@ mod tests { assert_eq!(ActiveLevel::default(), ActiveLevel::High); } + #[test] + fn test_atx_output_binding_default() { + let config = AtxOutputBinding::default(); + assert!(!config.enabled); + assert!(config.device.is_empty()); + assert_eq!(config.pin, 1); + assert!(!config.is_configured_for(AtxDriverType::Gpio, "")); + } + + #[test] + fn test_atx_output_binding_is_configured() { + let mut config = AtxOutputBinding::default(); + assert!(!config.is_configured_for(AtxDriverType::Gpio, "")); + + config.enabled = true; + assert!(!config.is_configured_for(AtxDriverType::Gpio, "")); + + config.device = "/dev/gpiochip0".to_string(); + assert!(config.is_configured_for(AtxDriverType::Gpio, "")); + assert!(!config.is_configured_for(AtxDriverType::Serial, "")); + assert!(config.is_configured_for(AtxDriverType::Serial, "/dev/ttyUSB0")); + } + #[test] fn test_atx_key_config_default() { let config = AtxKeyConfig::default(); @@ -149,37 +217,22 @@ mod tests { } #[test] - fn test_atx_key_config_is_configured() { - let mut config = AtxKeyConfig::default(); - assert!(!config.is_configured()); - - config.driver = AtxDriverType::Gpio; - assert!(!config.is_configured()); - - config.device = "/dev/gpiochip0".to_string(); - assert!(config.is_configured()); - - config.driver = AtxDriverType::None; - assert!(!config.is_configured()); - } - - #[test] - fn test_atx_led_config_default() { - let config = AtxLedConfig::default(); + fn test_atx_input_binding_default() { + let config = AtxInputBinding::default(); assert!(!config.enabled); - assert!(config.gpio_chip.is_empty()); + assert!(config.device.is_empty()); assert!(!config.is_configured()); } #[test] - fn test_atx_led_config_is_configured() { - let mut config = AtxLedConfig::default(); + fn test_atx_input_binding_is_configured() { + let mut config = AtxInputBinding::default(); assert!(!config.is_configured()); config.enabled = true; assert!(!config.is_configured()); - config.gpio_chip = "/dev/gpiochip0".to_string(); + config.device = "/dev/gpiochip0".to_string(); assert!(config.is_configured()); } @@ -187,9 +240,12 @@ mod tests { fn test_atx_state_default() { let state = AtxState::default(); assert!(!state.available); + assert_eq!(state.driver, AtxDriverType::None); assert!(!state.power_configured); assert!(!state.reset_configured); assert_eq!(state.power_status, PowerStatus::Unknown); assert!(!state.led_supported); + assert_eq!(state.hdd_status, HddStatus::Unknown); + assert!(!state.hdd_supported); } } diff --git a/src/config/schema/atx.rs b/src/config/schema/atx.rs index e7c7c5f1..92d9dd18 100644 --- a/src/config/schema/atx.rs +++ b/src/config/schema/atx.rs @@ -1,27 +1,61 @@ use serde::{Deserialize, Serialize}; use typeshare::typeshare; -pub use crate::atx::{ActiveLevel, AtxDriverType, AtxKeyConfig, AtxLedConfig}; +pub use crate::atx::{ActiveLevel, AtxDriverType, AtxInputBinding, AtxOutputBinding}; #[typeshare] #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default)] -#[derive(Default)] pub struct AtxConfig { pub enabled: bool, - pub power: AtxKeyConfig, - pub reset: AtxKeyConfig, - pub led: AtxLedConfig, + pub driver: AtxDriverType, + pub device: String, + pub baud_rate: u32, + pub power: AtxOutputBinding, + pub reset: AtxOutputBinding, + pub led: AtxInputBinding, + pub hdd: AtxInputBinding, pub wol_interface: String, } -impl AtxConfig { - pub fn to_controller_config(&self) -> crate::atx::AtxControllerConfig { - crate::atx::AtxControllerConfig { - enabled: self.enabled, - power: self.power.clone(), - reset: self.reset.clone(), - led: self.led.clone(), +impl Default for AtxConfig { + fn default() -> Self { + Self { + enabled: false, + driver: AtxDriverType::None, + device: String::new(), + baud_rate: 9600, + power: AtxOutputBinding::default(), + reset: AtxOutputBinding::default(), + led: AtxInputBinding::default(), + hdd: AtxInputBinding::default(), + wol_interface: String::new(), + } + } +} + +impl AtxConfig { + pub fn normalize(&mut self) { + if self.driver == AtxDriverType::None { + self.enabled = false; + } + + if self.driver != AtxDriverType::Gpio { + self.led.enabled = false; + self.hdd.enabled = false; + } + } + + pub fn to_controller_config(&self) -> crate::atx::AtxControllerConfig { + crate::atx::AtxControllerConfig { + enabled: self.enabled, + driver: self.driver, + device: self.device.clone(), + baud_rate: self.baud_rate, + power: self.power.clone(), + reset: self.reset.clone(), + led: self.led.clone(), + hdd: self.hdd.clone(), } } } diff --git a/src/config/schema/mod.rs b/src/config/schema/mod.rs index b922c276..2eda5a78 100644 --- a/src/config/schema/mod.rs +++ b/src/config/schema/mod.rs @@ -45,6 +45,7 @@ impl AppConfig { if self.hid.backend != HidBackend::Otg { self.msd.enabled = false; } + self.atx.normalize(); } pub fn apply_platform_defaults(&mut self) { diff --git a/src/events/types.rs b/src/events/types.rs index 18ff0815..1b320d53 100644 --- a/src/events/types.rs +++ b/src/events/types.rs @@ -54,6 +54,7 @@ pub struct AtxDeviceInfo { pub backend: String, pub initialized: bool, pub power_on: bool, + pub hdd_status: String, pub error: Option, } diff --git a/src/platform/defaults.rs b/src/platform/defaults.rs index 0741b691..ba951cf9 100644 --- a/src/platform/defaults.rs +++ b/src/platform/defaults.rs @@ -83,34 +83,23 @@ fn apply_windows(config: &mut AppConfig) { } if matches!( - config.atx.power.driver, + config.atx.driver, AtxDriverType::Gpio | AtxDriverType::UsbRelay ) { - config.atx.power.driver = AtxDriverType::None; - } - if matches!( - config.atx.reset.driver, - AtxDriverType::Gpio | AtxDriverType::UsbRelay - ) { - config.atx.reset.driver = AtxDriverType::None; + config.atx.driver = AtxDriverType::None; + config.atx.enabled = false; } if !config.initialized - && config.atx.power.driver == AtxDriverType::None - && config.atx.power.device.is_empty() + && config.atx.driver == AtxDriverType::None + && config.atx.device.is_empty() { - config.atx.power.driver = AtxDriverType::Serial; - config.atx.power.device = "COM4".to_string(); + config.atx.driver = AtxDriverType::Serial; + config.atx.device = "COM4".to_string(); + config.atx.baud_rate = 9600; + config.atx.power.enabled = true; config.atx.power.pin = 1; - config.atx.power.baud_rate = 9600; - } - if !config.initialized - && config.atx.reset.driver == AtxDriverType::None - && config.atx.reset.device.is_empty() - { - config.atx.reset.driver = AtxDriverType::Serial; - config.atx.reset.device = "COM4".to_string(); + config.atx.reset.enabled = true; config.atx.reset.pin = 2; - config.atx.reset.baud_rate = 9600; } config diff --git a/src/state.rs b/src/state.rs index 500aed53..bc8346ef 100644 --- a/src/state.rs +++ b/src/state.rs @@ -247,26 +247,27 @@ impl AppState { } async fn collect_atx_info(&self) -> Option { - const BACKEND_POWER_ONLY: &str = "power: configured, reset: none"; - const BACKEND_RESET_ONLY: &str = "power: none, reset: configured"; - const BACKEND_BOTH: &str = "power: configured, reset: configured"; - const BACKEND_NONE: &str = "none"; - let atx_guard = self.atx.read().await; let atx = atx_guard.as_ref()?; let state = atx.state().await; Some(AtxDeviceInfo { available: state.available, - backend: match (state.power_configured, state.reset_configured) { - (true, true) => BACKEND_BOTH, - (true, false) => BACKEND_POWER_ONLY, - (false, true) => BACKEND_RESET_ONLY, - (false, false) => BACKEND_NONE, + backend: match state.driver { + crate::atx::AtxDriverType::Gpio => "gpio", + crate::atx::AtxDriverType::UsbRelay => "usbrelay", + crate::atx::AtxDriverType::Serial => "serial", + crate::atx::AtxDriverType::None => "none", } .to_string(), initialized: state.power_configured || state.reset_configured, power_on: state.power_status == crate::atx::PowerStatus::On, + hdd_status: match state.hdd_status { + crate::atx::HddStatus::Active => "active", + crate::atx::HddStatus::Inactive => "inactive", + crate::atx::HddStatus::Unknown => "unknown", + } + .to_string(), error: None, }) } diff --git a/src/web/handlers/atx_api.rs b/src/web/handlers/atx_api.rs index 5dc6869f..d439d0c3 100644 --- a/src/web/handlers/atx_api.rs +++ b/src/web/handlers/atx_api.rs @@ -1,6 +1,6 @@ use super::*; -use crate::atx::{AtxState, PowerStatus}; +use crate::atx::{AtxDriverType, AtxState, HddStatus, PowerStatus}; const WOL_HISTORY_DEFAULT_LIMIT: usize = 5; const WOL_HISTORY_MAX_LIMIT: usize = 50; @@ -13,21 +13,21 @@ pub struct AtxStateResponse { pub initialized: bool, pub power_status: String, pub led_supported: bool, + pub hdd_status: String, + pub hdd_supported: bool, } impl From for AtxStateResponse { fn from(state: AtxState) -> Self { Self { available: state.available, - backend: if state.power_configured || state.reset_configured { - format!( - "power: {}, reset: {}", - if state.power_configured { "yes" } else { "no" }, - if state.reset_configured { "yes" } else { "no" } - ) - } else { - "none".to_string() - }, + backend: match state.driver { + AtxDriverType::Gpio => "gpio", + AtxDriverType::UsbRelay => "usbrelay", + AtxDriverType::Serial => "serial", + AtxDriverType::None => "none", + } + .to_string(), initialized: state.power_configured || state.reset_configured, power_status: match state.power_status { PowerStatus::On => "on".to_string(), @@ -35,6 +35,12 @@ impl From for AtxStateResponse { PowerStatus::Unknown => "unknown".to_string(), }, led_supported: state.led_supported, + hdd_status: match state.hdd_status { + HddStatus::Active => "active".to_string(), + HddStatus::Inactive => "inactive".to_string(), + HddStatus::Unknown => "unknown".to_string(), + }, + hdd_supported: state.hdd_supported, } } } @@ -54,6 +60,8 @@ pub async fn atx_status(State(state): State>) -> Result Result<()> { return Ok(()); } - for (name, key) in [("power", &atx.power), ("reset", &atx.reset)] { - if !matches!(key.driver, AtxDriverType::Serial | AtxDriverType::None) { - return Err(AppError::BadRequest(format!( - "Windows ATX {} only supports serial relay or none", - name - ))); - } + if !matches!(atx.driver, AtxDriverType::Serial | AtxDriverType::None) { + return Err(AppError::BadRequest( + "Windows ATX only supports serial relay or none".to_string(), + )); } Ok(()) @@ -68,13 +65,11 @@ fn validate_serial_device_conflict(atx: &AtxConfig, hid: &HidConfig) -> Result<( return Ok(()); } - for (name, key) in [("power", &atx.power), ("reset", &atx.reset)] { - if key.driver == AtxDriverType::Serial && key.device.trim() == reserved { - return Err(AppError::BadRequest(format!( - "ATX {} serial device '{}' conflicts with HID CH9329 serial device", - name, reserved - ))); - } + if atx.driver == AtxDriverType::Serial && atx.device.trim() == reserved { + return Err(AppError::BadRequest(format!( + "ATX serial device '{}' conflicts with HID CH9329 serial device", + reserved + ))); } Ok(()) @@ -87,8 +82,8 @@ mod tests { #[test] fn test_validate_serial_device_conflict_rejects_ch9329_overlap() { let mut atx = AtxConfig::default(); - atx.power.driver = AtxDriverType::Serial; - atx.power.device = "/dev/ttyUSB0".to_string(); + atx.driver = AtxDriverType::Serial; + atx.device = "/dev/ttyUSB0".to_string(); let mut hid = HidConfig::default(); hid.backend = HidBackend::Ch9329; @@ -100,8 +95,8 @@ mod tests { #[test] fn test_validate_serial_device_conflict_allows_non_ch9329_backend() { let mut atx = AtxConfig::default(); - atx.power.driver = AtxDriverType::Serial; - atx.power.device = "/dev/ttyUSB0".to_string(); + atx.driver = AtxDriverType::Serial; + atx.device = "/dev/ttyUSB0".to_string(); let mut hid = HidConfig::default(); hid.backend = HidBackend::None; @@ -113,8 +108,7 @@ mod tests { #[test] fn test_validate_windows_atx_backends_allows_serial() { let mut atx = AtxConfig::default(); - atx.power.driver = AtxDriverType::Serial; - atx.reset.driver = AtxDriverType::None; + atx.driver = AtxDriverType::Serial; assert!(validate_windows_atx_backends(&atx).is_ok()); } diff --git a/src/web/handlers/config/types.rs b/src/web/handlers/config/types.rs index b4ac5546..5a0e7796 100644 --- a/src/web/handlers/config/types.rs +++ b/src/web/handlers/config/types.rs @@ -453,25 +453,24 @@ impl MsdConfigUpdate { } } -/// Update for a single ATX key configuration +/// Update for a single ATX output binding #[typeshare] #[derive(Debug, Deserialize)] -pub struct AtxKeyConfigUpdate { - pub driver: Option, +pub struct AtxOutputBindingUpdate { + pub enabled: Option, pub device: Option, - pub baud_rate: Option, pub pin: Option, pub active_level: Option, } -/// Update for LED sensing configuration +/// Update for ATX GPIO input sensing #[typeshare] #[derive(Debug, Deserialize)] -pub struct AtxLedConfigUpdate { +pub struct AtxInputBindingUpdate { pub enabled: Option, - pub gpio_chip: Option, - pub gpio_pin: Option, - pub inverted: Option, + pub device: Option, + pub pin: Option, + pub active_level: Option, } /// ATX configuration update request @@ -479,26 +478,46 @@ pub struct AtxLedConfigUpdate { #[derive(Debug, Deserialize)] pub struct AtxConfigUpdate { pub enabled: Option, + pub driver: Option, + pub device: Option, + pub baud_rate: Option, /// Power button configuration - pub power: Option, + pub power: Option, /// Reset button configuration - pub reset: Option, + pub reset: Option, /// LED sensing configuration - pub led: Option, + pub led: Option, + /// HDD activity sensing configuration + pub hdd: Option, /// Network interface for WOL packets (empty = auto) pub wol_interface: Option, } impl AtxConfigUpdate { pub fn validate(&self) -> crate::error::Result<()> { - // Validate power key config if present - if let Some(ref power) = self.power { - Self::validate_key_config(power, "power")?; + if let Some(baud_rate) = self.baud_rate { + if baud_rate == 0 { + return Err(AppError::BadRequest( + "ATX baud_rate must be greater than 0".to_string(), + )); + } } - // Validate reset key config if present - if let Some(ref reset) = self.reset { - Self::validate_key_config(reset, "reset")?; + + for (name, binding) in [ + ("power", self.power.as_ref()), + ("reset", self.reset.as_ref()), + ] { + if let Some(binding) = binding { + Self::validate_output_binding_update(binding, name)?; + } } + + for (name, binding) in [("led", self.led.as_ref()), ("hdd", self.hdd.as_ref())] { + if let Some(binding) = binding { + Self::validate_input_binding_update(binding, name)?; + } + } + Ok(()) } @@ -509,152 +528,123 @@ impl AtxConfigUpdate { let mut merged = current.clone(); self.apply_to(&mut merged); - Self::validate_effective_key_config(&merged.power, "power")?; - Self::validate_effective_key_config(&merged.reset, "reset")?; - Self::validate_shared_serial_baud_rate(&merged)?; + if merged.driver == crate::atx::AtxDriverType::None { + if merged.enabled { + return Err(AppError::BadRequest( + "ATX driver must not be none when ATX is enabled".to_string(), + )); + } + return Ok(()); + } + + merged.normalize(); + + if !merged.enabled { + return Ok(()); + } + + Self::validate_effective_config(&merged)?; Ok(()) } - fn validate_shared_serial_baud_rate(config: &AtxConfig) -> crate::error::Result<()> { - let power = &config.power; - let reset = &config.reset; - let same_serial_device = power.driver == crate::atx::AtxDriverType::Serial - && reset.driver == crate::atx::AtxDriverType::Serial - && !power.device.trim().is_empty() - && power.device.trim() == reset.device.trim(); + fn validate_device_path(device: &str, name: &str) -> crate::error::Result<()> { + let trimmed = device.trim(); + if trimmed.is_empty() { + return Err(AppError::BadRequest(format!( + "{} device cannot be empty", + name + ))); + } - if same_serial_device && power.baud_rate != reset.baud_rate { - return Err(AppError::BadRequest( - "ATX power/reset sharing the same serial relay device must use one baud_rate" - .to_string(), - )); + if !cfg!(windows) && !std::path::Path::new(trimmed).exists() { + return Err(AppError::BadRequest(format!( + "{} device '{}' does not exist", + name, trimmed + ))); } Ok(()) } - fn validate_key_config(key: &AtxKeyConfigUpdate, name: &str) -> crate::error::Result<()> { - if let Some(ref device) = key.device { - if !device.trim().is_empty() && !cfg!(windows) && !std::path::Path::new(device).exists() - { - return Err(AppError::BadRequest(format!( - "{} device '{}' does not exist", - name, device - ))); - } - } - if let Some(baud_rate) = key.baud_rate { - if baud_rate == 0 { - return Err(AppError::BadRequest(format!( - "{} baud_rate must be greater than 0", - name - ))); - } - } - - if let Some(driver) = key.driver { - match driver { - crate::atx::AtxDriverType::Serial => { - if let Some(pin) = key.pin { - if pin == 0 { - return Err(AppError::BadRequest(format!( - "{} serial channel must be 1-based (>= 1)", - name - ))); - } - if pin > u8::MAX as u32 { - return Err(AppError::BadRequest(format!( - "{} serial channel must be <= {}", - name, - u8::MAX - ))); - } - } - } - crate::atx::AtxDriverType::UsbRelay => { - if let Some(pin) = key.pin { - if pin == 0 { - return Err(AppError::BadRequest(format!( - "{} USB relay channel must be 1-based (>= 1)", - name - ))); - } - if pin > u8::MAX as u32 { - return Err(AppError::BadRequest(format!( - "{} USB relay channel must be <= {}", - name, - u8::MAX - ))); - } - if pin > 8 { - return Err(AppError::BadRequest(format!( - "{} USB HID relay channel must be <= 8", - name - ))); - } - } - } - crate::atx::AtxDriverType::Gpio | crate::atx::AtxDriverType::None => {} - } - } - Ok(()) - } - - fn validate_effective_key_config( - key: &crate::atx::AtxKeyConfig, + fn validate_output_binding_update( + binding: &AtxOutputBindingUpdate, name: &str, ) -> crate::error::Result<()> { - match key.driver { - crate::atx::AtxDriverType::Serial => { - if key.device.trim().is_empty() { - return Err(AppError::BadRequest(format!( - "{} serial device cannot be empty", - name - ))); + if let Some(ref device) = binding.device { + if !device.trim().is_empty() { + Self::validate_device_path(device, name)?; + } + } + + Ok(()) + } + + fn validate_input_binding_update( + binding: &AtxInputBindingUpdate, + name: &str, + ) -> crate::error::Result<()> { + if let Some(ref device) = binding.device { + if !device.trim().is_empty() { + Self::validate_device_path(device, name)?; + } + } + + Ok(()) + } + + fn validate_effective_config(config: &AtxConfig) -> crate::error::Result<()> { + match config.driver { + crate::atx::AtxDriverType::Gpio => { + for (name, binding) in [("power", &config.power), ("reset", &config.reset)] { + if binding.enabled { + Self::validate_device_path(&binding.device, name)?; + } } - if key.pin == 0 { - return Err(AppError::BadRequest(format!( - "{} serial channel must be 1-based (>= 1)", - name - ))); - } - if key.pin > u8::MAX as u32 { - return Err(AppError::BadRequest(format!( - "{} serial channel must be <= {}", - name, - u8::MAX - ))); - } - if key.baud_rate == 0 { - return Err(AppError::BadRequest(format!( - "{} baud_rate must be greater than 0", - name - ))); + + for (name, binding) in [("led", &config.led), ("hdd", &config.hdd)] { + if binding.enabled { + Self::validate_device_path(&binding.device, name)?; + } } } crate::atx::AtxDriverType::UsbRelay => { - if key.pin == 0 { - return Err(AppError::BadRequest(format!( - "{} USB relay channel must be 1-based (>= 1)", - name - ))); - } - if key.pin > u8::MAX as u32 { - return Err(AppError::BadRequest(format!( - "{} USB relay channel must be <= {}", - name, - u8::MAX - ))); - } - if key.pin > 8 { - return Err(AppError::BadRequest(format!( - "{} USB HID relay channel must be <= 8", - name - ))); - } + Self::validate_device_path(&config.device, "ATX USB relay")?; + Self::validate_output_channel(&config.power, "power", 1, 8)?; + Self::validate_output_channel(&config.reset, "reset", 1, 8)?; } - crate::atx::AtxDriverType::Gpio | crate::atx::AtxDriverType::None => {} + crate::atx::AtxDriverType::Serial => { + Self::validate_device_path(&config.device, "ATX serial")?; + if config.baud_rate == 0 { + return Err(AppError::BadRequest( + "ATX baud_rate must be greater than 0".to_string(), + )); + } + Self::validate_output_channel(&config.power, "power", 1, u8::MAX as u32)?; + Self::validate_output_channel(&config.reset, "reset", 1, u8::MAX as u32)?; + } + crate::atx::AtxDriverType::None => {} + }; + + Ok(()) + } + + fn validate_output_channel( + binding: &crate::atx::AtxOutputBinding, + name: &str, + min: u32, + max: u32, + ) -> crate::error::Result<()> { + if !binding.enabled { + return Ok(()); } + + if binding.pin < min || binding.pin > max { + return Err(AppError::BadRequest(format!( + "{} channel must be between {} and {}", + name, min, max + ))); + } + Ok(()) } @@ -662,50 +652,65 @@ impl AtxConfigUpdate { if let Some(enabled) = self.enabled { config.enabled = enabled; } + if let Some(driver) = self.driver { + config.driver = driver; + } + if let Some(ref device) = self.device { + config.device = device.clone(); + } + if let Some(baud_rate) = self.baud_rate { + config.baud_rate = baud_rate; + } if let Some(ref power) = self.power { - Self::apply_key_update(power, &mut config.power); + Self::apply_output_binding_update(power, &mut config.power); } if let Some(ref reset) = self.reset { - Self::apply_key_update(reset, &mut config.reset); + Self::apply_output_binding_update(reset, &mut config.reset); } if let Some(ref led) = self.led { - Self::apply_led_update(led, &mut config.led); + Self::apply_input_binding_update(led, &mut config.led); + } + if let Some(ref hdd) = self.hdd { + Self::apply_input_binding_update(hdd, &mut config.hdd); } if let Some(ref wol_interface) = self.wol_interface { config.wol_interface = wol_interface.clone(); } } - fn apply_key_update(update: &AtxKeyConfigUpdate, config: &mut crate::atx::AtxKeyConfig) { - if let Some(driver) = update.driver { - config.driver = driver; + fn apply_output_binding_update( + update: &AtxOutputBindingUpdate, + config: &mut crate::atx::AtxOutputBinding, + ) { + if let Some(enabled) = update.enabled { + config.enabled = enabled; } if let Some(ref device) = update.device { config.device = device.clone(); } - if let Some(baud_rate) = update.baud_rate { - config.baud_rate = baud_rate; + if let Some(pin) = update.pin { + config.pin = pin; + } + if let Some(active_level) = update.active_level { + config.active_level = active_level; + } + } + + fn apply_input_binding_update( + update: &AtxInputBindingUpdate, + config: &mut crate::atx::AtxInputBinding, + ) { + if let Some(enabled) = update.enabled { + config.enabled = enabled; + } + if let Some(ref device) = update.device { + config.device = device.clone(); } if let Some(pin) = update.pin { config.pin = pin; } - if let Some(level) = update.active_level { - config.active_level = level; - } - } - - fn apply_led_update(update: &AtxLedConfigUpdate, config: &mut crate::atx::AtxLedConfig) { - if let Some(enabled) = update.enabled { - config.enabled = enabled; - } - if let Some(ref chip) = update.gpio_chip { - config.gpio_chip = chip.clone(); - } - if let Some(pin) = update.gpio_pin { - config.gpio_pin = pin; - } - if let Some(inverted) = update.inverted { - config.inverted = inverted; + if let Some(active_level) = update.active_level { + config.active_level = active_level; } } } @@ -1233,77 +1238,90 @@ impl RedfishConfigUpdate { mod tests { use super::*; - #[test] - fn test_atx_apply_key_update_applies_baud_rate() { - let mut config = AtxConfig::default(); - let update = AtxConfigUpdate { + fn empty_atx_update() -> AtxConfigUpdate { + AtxConfigUpdate { enabled: None, - power: Some(AtxKeyConfigUpdate { - driver: Some(crate::atx::AtxDriverType::Serial), - device: Some("/dev/null".to_string()), - baud_rate: Some(19200), - pin: Some(1), - active_level: None, - }), + driver: None, + device: None, + baud_rate: None, + power: None, reset: None, led: None, + hdd: None, wol_interface: None, - }; + } + } + + #[test] + fn test_atx_apply_update_applies_global_baud_rate() { + let mut config = AtxConfig::default(); + let mut update = empty_atx_update(); + update.driver = Some(crate::atx::AtxDriverType::Serial); + update.device = Some("/dev/null".to_string()); + update.baud_rate = Some(19200); + update.power = Some(AtxOutputBindingUpdate { + enabled: Some(true), + device: Some("/dev/ignored".to_string()), + pin: Some(1), + active_level: None, + }); update.apply_to(&mut config); - assert_eq!(config.power.baud_rate, 19200); + assert_eq!(config.driver, crate::atx::AtxDriverType::Serial); + assert_eq!(config.device, "/dev/null"); + assert_eq!(config.baud_rate, 19200); + assert!(config.power.enabled); + assert_eq!(config.power.pin, 1); } #[test] fn test_atx_validate_with_current_rejects_serial_pin_zero() { let mut current = AtxConfig::default(); - current.power.driver = crate::atx::AtxDriverType::Serial; - current.power.device = "/dev/null".to_string(); + current.enabled = true; + current.driver = crate::atx::AtxDriverType::Serial; + current.device = "/dev/null".to_string(); + current.power.enabled = true; current.power.pin = 1; - current.power.baud_rate = 9600; + current.baud_rate = 9600; - let update = AtxConfigUpdate { + let mut update = empty_atx_update(); + update.power = Some(AtxOutputBindingUpdate { enabled: None, - power: Some(AtxKeyConfigUpdate { - driver: None, - device: None, - baud_rate: None, - pin: Some(0), - active_level: None, - }), - reset: None, - led: None, - wol_interface: None, - }; + device: None, + pin: Some(0), + active_level: None, + }); assert!(update.validate_with_current(¤t).is_err()); } #[test] - fn test_atx_validate_with_current_rejects_shared_serial_baud_mismatch() { + fn test_atx_validate_with_current_rejects_enabled_none_driver() { let mut current = AtxConfig::default(); - current.power.driver = crate::atx::AtxDriverType::Serial; - current.power.device = "/dev/ttyUSB0".to_string(); - current.power.pin = 1; - current.power.baud_rate = 9600; - current.reset.driver = crate::atx::AtxDriverType::Serial; - current.reset.device = "/dev/ttyUSB0".to_string(); - current.reset.pin = 2; - current.reset.baud_rate = 9600; + current.driver = crate::atx::AtxDriverType::None; - let update = AtxConfigUpdate { + let mut update = empty_atx_update(); + update.enabled = Some(true); + + assert!(update.validate_with_current(¤t).is_err()); + } + + #[test] + fn test_atx_validate_with_current_rejects_usb_relay_channel_over_8() { + let mut current = AtxConfig::default(); + current.enabled = true; + current.driver = crate::atx::AtxDriverType::UsbRelay; + current.device = "/dev/null".to_string(); + current.power.enabled = true; + current.power.pin = 1; + + let mut update = empty_atx_update(); + update.power = Some(AtxOutputBindingUpdate { enabled: None, - power: None, - reset: Some(AtxKeyConfigUpdate { - driver: None, - device: None, - baud_rate: Some(115200), - pin: None, - active_level: None, - }), - led: None, - wol_interface: None, - }; + device: None, + pin: Some(9), + active_level: None, + }); assert!(update.validate_with_current(¤t).is_err()); } diff --git a/src/web/handlers/system.rs b/src/web/handlers/system.rs index 15ded06f..a673d3ae 100644 --- a/src/web/handlers/system.rs +++ b/src/web/handlers/system.rs @@ -88,10 +88,7 @@ pub async fn system_info(State(state): State>) -> Json atx: CapabilityInfo { available: config.atx.enabled, backend: if config.atx.enabled { - Some(format!( - "power: {:?}, reset: {:?}", - config.atx.power.driver, config.atx.reset.driver - )) + Some(format!("{:?}", config.atx.driver)) } else { None }, diff --git a/web/src/api/index.ts b/web/src/api/index.ts index a2b54e2c..9f6042c8 100644 --- a/web/src/api/index.ts +++ b/web/src/api/index.ts @@ -549,6 +549,8 @@ export const atxApi = { initialized: boolean power_status: 'on' | 'off' | 'unknown' led_supported: boolean + hdd_status: 'active' | 'inactive' | 'unknown' + hdd_supported: boolean }>('/atx/status'), power: (action: 'short' | 'long' | 'reset') => diff --git a/web/src/components/AtxPopover.vue b/web/src/components/AtxPopover.vue index 50e5fe6d..8345fbdd 100644 --- a/web/src/components/AtxPopover.vue +++ b/web/src/components/AtxPopover.vue @@ -2,25 +2,23 @@ import { ref, computed, watch, onMounted, onUnmounted } from 'vue' import { useI18n } from 'vue-i18n' import { Button } from '@/components/ui/button' -import { Badge } from '@/components/ui/badge' import { Separator } from '@/components/ui/separator' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from '@/components/ui/alert-dialog' -import { Power, RotateCcw, CircleDot, Wifi, Send } from 'lucide-vue-next' +import { Power, RotateCcw, CircleDot, Wifi, Send, HardDrive } from 'lucide-vue-next' import { atxApi } from '@/api' import { atxConfigApi } from '@/api/config' +type AtxAction = 'short' | 'long' | 'reset' + +const minActionFeedbackMs = 800 +const actionDurations: Record = { + short: 500, + long: 5000, + reset: 500, +} + const emit = defineEmits<{ (e: 'close'): void (e: 'powerShort'): void @@ -34,21 +32,23 @@ const { t } = useI18n() const activeTab = ref('atx') const powerState = ref<'on' | 'off' | 'unknown'>('unknown') +const hddState = ref<'active' | 'inactive' | 'unknown'>('unknown') let powerStateTimer: number | null = null -// Decouple action data from dialog visibility to prevent race conditions -const pendingAction = ref<'short' | 'long' | 'reset' | null>(null) -const confirmDialogOpen = ref(false) +let actionTimer: number | null = null const wolMacAddress = ref('') const wolHistory = ref([]) const wolSending = ref(false) const wolLoadingHistory = ref(false) +const activeAction = ref(null) -const powerStateColor = computed(() => { +const actionBusy = computed(() => activeAction.value !== null) + +const powerStateIconColor = computed(() => { switch (powerState.value) { - case 'on': return 'bg-green-500' - case 'off': return 'bg-slate-400' - default: return 'bg-yellow-500' + case 'on': return 'text-green-600' + case 'off': return 'text-slate-500' + default: return 'text-yellow-600' } }) @@ -60,39 +60,42 @@ const powerStateText = computed(() => { } }) -function requestAction(action: 'short' | 'long' | 'reset') { - pendingAction.value = action - confirmDialogOpen.value = true -} +const hddStateIconColor = computed(() => { + switch (hddState.value) { + case 'active': return 'text-sky-600' + case 'inactive': return 'text-slate-500' + default: return 'text-yellow-600' + } +}) -function handleAction() { - console.log('[AtxPopover] Confirming action:', pendingAction.value) - if (pendingAction.value === 'short') emit('powerShort') - else if (pendingAction.value === 'long') emit('powerLong') - else if (pendingAction.value === 'reset') emit('reset') - confirmDialogOpen.value = false - setTimeout(() => { +const hddStateText = computed(() => { + switch (hddState.value) { + case 'active': return t('atx.hddActive') + case 'inactive': return t('atx.hddInactive') + default: return t('atx.stateUnknown') + } +}) + +function handleAction(action: AtxAction) { + if (actionBusy.value) return + + console.log('[AtxPopover] Running action:', action) + activeAction.value = action + + if (action === 'short') emit('powerShort') + else if (action === 'long') emit('powerLong') + else emit('reset') + + if (actionTimer !== null) { + window.clearTimeout(actionTimer) + } + actionTimer = window.setTimeout(() => { + activeAction.value = null + actionTimer = null refreshPowerState().catch(() => {}) - }, 1200) + }, Math.max(actionDurations[action], minActionFeedbackMs)) } -const confirmTitle = computed(() => { - switch (pendingAction.value) { - case 'short': return t('atx.confirmShortTitle') - case 'long': return t('atx.confirmLongTitle') - case 'reset': return t('atx.confirmResetTitle') - default: return '' - } -}) - -const confirmDescription = computed(() => { - switch (pendingAction.value) { - case 'short': return t('atx.confirmShortDesc') - case 'long': return t('atx.confirmLongDesc') - case 'reset': return t('atx.confirmResetDesc') - default: return '' - } -}) const isValidMac = computed(() => { const mac = wolMacAddress.value.trim() const macRegex = /^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$|^([0-9A-Fa-f]{12})$/ @@ -142,8 +145,10 @@ async function refreshPowerState() { try { const state = await atxApi.status() powerState.value = state.power_status + hddState.value = state.hdd_status } catch { powerState.value = 'unknown' + hddState.value = 'unknown' } } @@ -159,6 +164,10 @@ onUnmounted(() => { window.clearInterval(powerStateTimer) powerStateTimer = null } + if (actionTimer !== null) { + window.clearTimeout(actionTimer) + actionTimer = null + } }) watch( @@ -173,70 +182,91 @@ watch( diff --git a/web/src/i18n/en-US.ts b/web/src/i18n/en-US.ts index c0001cf9..a24f6ba5 100644 --- a/web/src/i18n/en-US.ts +++ b/web/src/i18n/en-US.ts @@ -193,18 +193,15 @@ export default { title: 'Power Control', description: 'Control remote host power state', powerState: 'Power State', + hddState: 'HDD Activity', stateOn: 'On', stateOff: 'Off', stateUnknown: 'Unknown', + hddActive: 'Active', + hddInactive: 'Inactive', shortPress: 'Power (Short)', longPress: 'Power (Long/Force Off)', reset: 'Reset', - confirmShortTitle: 'Confirm Power Press', - confirmShortDesc: 'This will simulate pressing the power button, same as a physical short press.', - confirmLongTitle: 'Confirm Force Power Off', - confirmLongDesc: 'This will force power off the host, which may cause data loss. Are you sure?', - confirmResetTitle: 'Confirm Reset', - confirmResetDesc: 'This will reset the host, which may cause unsaved data loss. Are you sure?', wol: 'Wake-on-LAN', wolDescription: 'Send Wake-on-LAN magic packet to power on a remote machine.', macAddress: 'MAC Address', @@ -548,7 +545,6 @@ export default { detecting: 'Detecting...', networkSettings: 'Network Settings', msdSettings: 'MSD Settings', - atxSettings: 'ATX Settings', httpSettings: 'HTTP Settings', httpPort: 'HTTP Port', configureHttpPort: 'Configure HTTP server port', @@ -676,33 +672,22 @@ export default { disabled: 'Disabled', msdDesc: 'Mass Storage Device allows you to mount ISO images and virtual drives to the target machine. Use the MSD panel on the main page to manage images.', atxDesc: 'ATX power control allows you to remotely power on/off and reset the target machine. Use the ATX panel on the main page to control power.', - atxSettingsDesc: 'Configure ATX power control hardware bindings', - atxEnable: 'Enable ATX Control', - atxEnableDesc: 'Enable remote control of power and reset buttons', atxPowerButton: 'Power Button', - atxPowerButtonDesc: 'For power on (short press) and force off (long press)', atxResetButton: 'Reset Button', - atxResetButtonDesc: 'For resetting the target machine', atxDriver: 'Driver Type', atxDriverNone: 'Disabled', atxDriverGpio: 'GPIO', atxDriverUsbRelay: 'USB LCUS HID Relay', atxDriverSerial: 'USB LCUS Serial Relay', atxDevice: 'Device', + atxGpioChip: 'GPIO Chip', atxPin: 'GPIO Pin', atxChannel: 'Relay Channel', - atxSharedSerialBaudHint: 'When Power and Reset share one serial relay device, baud rate is controlled by the first config', - atxActiveLevel: 'Active Level', - atxLevelHigh: 'Active High', - atxLevelLow: 'Active Low', + atxActiveLevel: 'Trigger Level', + atxLevelHigh: 'High-level trigger', + atxLevelLow: 'Low-level trigger', atxLedSensing: 'LED Status Sensing', - atxLedSensingDesc: 'Detect host power LED to determine power state (optional)', - atxLedEnable: 'Enable LED Sensing', - atxLedEnableDesc: 'Read power LED status via GPIO', - atxLedChip: 'GPIO Chip', - atxLedPin: 'GPIO Pin', - atxLedInverted: 'Invert Logic', - atxLedInvertedDesc: 'GPIO is low when LED is on', + atxHddSensing: 'HDD Activity Sensing', atxWolSettings: 'Wake-on-LAN Settings', atxWolSettingsDesc: 'Configure WOL magic packet sending options', atxWolInterface: 'Network Interface', diff --git a/web/src/i18n/zh-CN.ts b/web/src/i18n/zh-CN.ts index de7ab67e..83127b6a 100644 --- a/web/src/i18n/zh-CN.ts +++ b/web/src/i18n/zh-CN.ts @@ -193,18 +193,15 @@ export default { title: '电源控制', description: '控制远程主机的电源状态', powerState: '电源状态', + hddState: '硬盘活动', stateOn: '已开机', stateOff: '已关机', stateUnknown: '未知', + hddActive: '活动', + hddInactive: '空闲', shortPress: '短按电源', longPress: '长按电源 (强制关机)', reset: '重启', - confirmShortTitle: '确认短按电源', - confirmShortDesc: '这将模拟按下电源按钮,与物理短按电源键效果相同。', - confirmLongTitle: '确认强制关机', - confirmLongDesc: '这将强制关闭主机,可能导致数据丢失。确定继续吗?', - confirmResetTitle: '确认重启', - confirmResetDesc: '这将重启主机,可能导致未保存的数据丢失。确定继续吗?', wol: '网络唤醒', wolDescription: '发送 Wake-on-LAN 魔术包以远程开机。', macAddress: 'MAC 地址', @@ -547,7 +544,6 @@ export default { detecting: '探测中...', networkSettings: '网络设置', msdSettings: 'MSD 设置', - atxSettings: 'ATX 设置', httpSettings: 'HTTP 设置', httpPort: 'HTTP 端口', configureHttpPort: '配置 HTTP 服务器端口', @@ -675,33 +671,22 @@ export default { disabled: '已禁用', msdDesc: '虚拟存储设备允许您将 ISO 镜像和虚拟驱动器挂载到目标机器。请在主页面的 MSD 面板中管理镜像。', atxDesc: 'ATX 电源控制允许您远程开关机和重启目标机器。请在主页面的 ATX 面板中控制电源。', - atxSettingsDesc: '配置 ATX 电源控制硬件绑定', - atxEnable: '启用 ATX 控制', - atxEnableDesc: '启用后可以远程控制电源和重启按钮', atxPowerButton: '电源按钮', - atxPowerButtonDesc: '用于开机(短按)和强制关机(长按)', atxResetButton: '重启按钮', - atxResetButtonDesc: '用于重启目标机器', atxDriver: '驱动类型', atxDriverNone: '禁用', atxDriverGpio: 'GPIO', atxDriverUsbRelay: 'USB LCUS HID继电器', atxDriverSerial: 'USB LCUS 串口继电器', atxDevice: '设备', + atxGpioChip: 'GPIO 芯片', atxPin: 'GPIO 引脚', atxChannel: '继电器通道', - atxSharedSerialBaudHint: 'Power 与 Reset 使用同一串口继电器时,波特率由第一个配置统一控制', - atxActiveLevel: '有效电平', - atxLevelHigh: '高电平有效', - atxLevelLow: '低电平有效', + atxActiveLevel: '触发电平', + atxLevelHigh: '高电平触发', + atxLevelLow: '低电平触发', atxLedSensing: 'LED 状态检测', - atxLedSensingDesc: '检测主机电源 LED 以确定电源状态(可选)', - atxLedEnable: '启用 LED 检测', - atxLedEnableDesc: '通过 GPIO 读取电源 LED 状态', - atxLedChip: 'GPIO 芯片', - atxLedPin: 'GPIO 引脚', - atxLedInverted: '反转逻辑', - atxLedInvertedDesc: 'LED 亮起时 GPIO 为低电平', + atxHddSensing: 'HDD 活动检测', atxWolSettings: '网络唤醒设置', atxWolSettingsDesc: '配置 Wake-on-LAN 魔术包发送选项', atxWolInterface: '网络接口', diff --git a/web/src/stores/system.ts b/web/src/stores/system.ts index cea074cb..85a0b4fc 100644 --- a/web/src/stores/system.ts +++ b/web/src/stores/system.ts @@ -50,6 +50,7 @@ interface AtxState { backend: string initialized: boolean powerOn: boolean + hddStatus: 'active' | 'inactive' | 'unknown' error: string | null } @@ -121,6 +122,7 @@ export interface AtxDeviceInfo { backend: string initialized: boolean power_on: boolean + hdd_status: 'active' | 'inactive' | 'unknown' error: string | null } @@ -235,6 +237,7 @@ export const useSystemStore = defineStore('system', () => { backend: state.backend, initialized: state.initialized, powerOn: state.power_status === 'on', + hddStatus: state.hdd_status, error: null, } return state @@ -376,6 +379,7 @@ export const useSystemStore = defineStore('system', () => { backend: data.atx.backend, initialized: data.atx.initialized, powerOn: data.atx.power_on, + hddStatus: data.atx.hdd_status, error: data.atx.error, } } else { diff --git a/web/src/types/generated.ts b/web/src/types/generated.ts index 4872aff8..7953a4be 100644 --- a/web/src/types/generated.ts +++ b/web/src/types/generated.ts @@ -94,26 +94,29 @@ export enum ActiveLevel { Low = "low", } -export interface AtxKeyConfig { - driver: AtxDriverType; +export interface AtxOutputBinding { + enabled: boolean; device: string; pin: number; active_level: ActiveLevel; - baud_rate: number; } -export interface AtxLedConfig { +export interface AtxInputBinding { enabled: boolean; - gpio_chip: string; - gpio_pin: number; - inverted: boolean; + device: string; + pin: number; + active_level: ActiveLevel; } export interface AtxConfig { enabled: boolean; - power: AtxKeyConfig; - reset: AtxKeyConfig; - led: AtxLedConfig; + driver: AtxDriverType; + device: string; + baud_rate: number; + power: AtxOutputBinding; + reset: AtxOutputBinding; + led: AtxInputBinding; + hdd: AtxInputBinding; wol_interface: string; } @@ -287,32 +290,36 @@ export interface AppConfig { redfish: RedfishConfig; } -/** Update for a single ATX key configuration */ -export interface AtxKeyConfigUpdate { - driver?: AtxDriverType; +/** Update for a single ATX output binding */ +export interface AtxOutputBindingUpdate { + enabled?: boolean; device?: string; - baud_rate?: number; pin?: number; active_level?: ActiveLevel; } -/** Update for LED sensing configuration */ -export interface AtxLedConfigUpdate { +/** Update for ATX GPIO input sensing */ +export interface AtxInputBindingUpdate { enabled?: boolean; - gpio_chip?: string; - gpio_pin?: number; - inverted?: boolean; + device?: string; + pin?: number; + active_level?: ActiveLevel; } /** ATX configuration update request */ export interface AtxConfigUpdate { enabled?: boolean; + driver?: AtxDriverType; + device?: string; + baud_rate?: number; /** Power button configuration */ - power?: AtxKeyConfigUpdate; + power?: AtxOutputBindingUpdate; /** Reset button configuration */ - reset?: AtxKeyConfigUpdate; + reset?: AtxOutputBindingUpdate; /** LED sensing configuration */ - led?: AtxLedConfigUpdate; + led?: AtxInputBindingUpdate; + /** HDD activity sensing configuration */ + hdd?: AtxInputBindingUpdate; /** Network interface for WOL packets (empty = auto) */ wol_interface?: string; } diff --git a/web/src/views/SettingsView.vue b/web/src/views/SettingsView.vue index a086353a..9e7db31a 100644 --- a/web/src/views/SettingsView.vue +++ b/web/src/views/SettingsView.vue @@ -1137,29 +1137,41 @@ watch(bindMode, (mode) => { const atxConfig = ref({ enabled: false, + driver: 'none' as AtxDriverType, + device: '', + baud_rate: 9600, power: { - driver: 'none' as AtxDriverType, + enabled: false, device: '', pin: 1, active_level: 'high' as ActiveLevel, - baud_rate: 9600, }, reset: { - driver: 'none' as AtxDriverType, + enabled: false, device: '', pin: 1, active_level: 'high' as ActiveLevel, - baud_rate: 9600, }, led: { enabled: false, - gpio_chip: '', - gpio_pin: 0, - inverted: false, + device: '', + pin: 0, + active_level: 'high' as ActiveLevel, + }, + hdd: { + enabled: false, + device: '', + pin: 0, + active_level: 'high' as ActiveLevel, }, wol_interface: '', }) +const atxSaving = ref(false) +const atxSaved = ref(false) +const wolSaving = ref(false) +const wolSaved = ref(false) + const atxDevices = ref({ gpio_chips: [], usb_relays: [], @@ -1183,17 +1195,6 @@ const atxDriverOptions = computed(() => { : options }) -const isSharedAtxSerialRelay = computed(() => { - const power = atxConfig.value.power - const reset = atxConfig.value.reset - return ( - power.driver === 'serial' - && reset.driver === 'serial' - && !!power.device.trim() - && power.device === reset.device - ) -}) - const availableBackends = ref([]) const selectedBackendFormats = computed(() => { @@ -1737,14 +1738,29 @@ async function loadAtxConfig() { const config = await configStore.refreshAtx() atxConfig.value = { enabled: config.enabled, - power: { ...config.power }, - reset: { ...config.reset }, - led: { ...config.led }, + driver: config.enabled ? config.driver : 'none' as AtxDriverType, + device: config.device || '', + baud_rate: config.baud_rate || 9600, + power: { + ...config.power, + active_level: config.power.active_level || 'high', + }, + reset: { + ...config.reset, + active_level: config.reset.active_level || 'high', + }, + led: { + ...config.led, + active_level: config.led.active_level || 'high', + }, + hdd: { + ...config.hdd, + active_level: config.hdd.active_level || 'high', + }, wol_interface: config.wol_interface || '', } clearAtxSerialDeviceConflicts() normalizeAtxRelayChannels() - syncSharedAtxSerialBaudRate() } catch { } } @@ -1756,43 +1772,64 @@ async function loadAtxDevices() { } } -async function saveAtxConfig() { - loading.value = true - saved.value = false +async function saveAtxSettings() { + atxSaving.value = true + atxSaved.value = false try { normalizeAtxRelayChannels() - syncSharedAtxSerialBaudRate() + const isGpio = atxConfig.value.driver === 'gpio' + const isRelay = ['usbrelay', 'serial'].includes(atxConfig.value.driver) await configStore.updateAtx({ - enabled: atxConfig.value.enabled, + enabled: atxConfig.value.driver !== 'none', + driver: atxConfig.value.driver, + device: atxConfig.value.device || undefined, + baud_rate: atxConfig.value.baud_rate, power: { - driver: atxConfig.value.power.driver, + enabled: isGpio ? !!atxConfig.value.power.device : isRelay, device: atxConfig.value.power.device || undefined, pin: atxConfig.value.power.pin, active_level: atxConfig.value.power.active_level, - baud_rate: atxConfig.value.power.baud_rate, }, reset: { - driver: atxConfig.value.reset.driver, + enabled: isGpio ? !!atxConfig.value.reset.device : isRelay, device: atxConfig.value.reset.device || undefined, pin: atxConfig.value.reset.pin, active_level: atxConfig.value.reset.active_level, - baud_rate: isSharedAtxSerialRelay.value - ? atxConfig.value.power.baud_rate - : atxConfig.value.reset.baud_rate, }, led: { - enabled: atxConfig.value.led.enabled, - gpio_chip: atxConfig.value.led.gpio_chip || undefined, - gpio_pin: atxConfig.value.led.gpio_pin, - inverted: atxConfig.value.led.inverted, + enabled: isGpio && !!atxConfig.value.led.device, + device: atxConfig.value.led.device || undefined, + pin: atxConfig.value.led.pin, + active_level: atxConfig.value.led.active_level, + }, + hdd: { + enabled: isGpio && !!atxConfig.value.hdd.device, + device: atxConfig.value.hdd.device || undefined, + pin: atxConfig.value.hdd.pin, + active_level: atxConfig.value.hdd.active_level, }, - wol_interface: atxConfig.value.wol_interface || undefined, }) - saved.value = true - setTimeout(() => (saved.value = false), 2000) + atxConfig.value.enabled = atxConfig.value.driver !== 'none' + atxSaved.value = true + setTimeout(() => (atxSaved.value = false), 2000) } catch { } finally { - loading.value = false + atxSaving.value = false + } +} + +async function saveWolSettings() { + wolSaving.value = true + wolSaved.value = false + try { + await configStore.updateAtx({ + wol_interface: atxConfig.value.wol_interface || undefined, + }) + wolSaved.value = true + setTimeout(() => (wolSaved.value = false), 2000) + } catch { + } finally { + wolSaving.value = false } } @@ -1823,25 +1860,21 @@ function clearAtxSerialDeviceConflicts() { const reserved = ch9329ReservedSerialDevice.value if (!reserved) return - if (atxConfig.value.power.driver === 'serial' && atxConfig.value.power.device === reserved) { - atxConfig.value.power.device = '' + if (atxConfig.value.driver === 'serial' && atxConfig.value.device === reserved) { + atxConfig.value.device = '' } - if (atxConfig.value.reset.driver === 'serial' && atxConfig.value.reset.device === reserved) { - atxConfig.value.reset.device = '' - } -} - -function syncSharedAtxSerialBaudRate() { - if (!isSharedAtxSerialRelay.value) return - atxConfig.value.reset.baud_rate = atxConfig.value.power.baud_rate } function normalizeAtxRelayChannels() { for (const key of [atxConfig.value.power, atxConfig.value.reset]) { - if (['usbrelay', 'serial'].includes(key.driver) && key.pin < 1) { + if (['usbrelay', 'serial'].includes(atxConfig.value.driver) && key.pin < 1) { key.pin = 1 } } + if (atxConfig.value.driver !== 'gpio') { + atxConfig.value.led.enabled = false + atxConfig.value.hdd.enabled = false + } } watch( @@ -1852,22 +1885,10 @@ watch( ) watch( - () => [atxConfig.value.power.driver, atxConfig.value.reset.driver], + () => atxConfig.value.driver, () => { normalizeAtxRelayChannels() - }, -) - -watch( - () => [ - atxConfig.value.power.driver, - atxConfig.value.power.device, - atxConfig.value.power.baud_rate, - atxConfig.value.reset.driver, - atxConfig.value.reset.device, - ], - () => { - syncSharedAtxSerialBaudRate() + clearAtxSerialDeviceConflicts() }, ) @@ -3947,83 +3968,40 @@ watch(isWindows, () => {
- - -
- {{ t('settings.atxSettings') }} - {{ t('settings.atxSettingsDesc') }} -
- -
- -
-
- -

{{ t('settings.atxEnableDesc') }}

-
- -
-
-
- - - - - {{ t('settings.atxPowerButton') }} - {{ t('settings.atxPowerButtonDesc') }} - - -
-
- -
-
- -
-
-
-
- - -
-
- - -
-
- - @@ -4032,128 +4010,138 @@ watch(isWindows, () => {
-
-
- - - - {{ t('settings.atxResetButton') }} - {{ t('settings.atxResetButtonDesc') }} - - -
-
- - -
-
- - -
-
-
-
- - -
-
- - -
-
- - -

- {{ t('settings.atxSharedSerialBaudHint') }} -

-
-
-
-
- - - - - {{ t('settings.atxLedSensing') }} - {{ t('settings.atxLedSensingDesc') }} - - -
-
- -

{{ t('settings.atxLedEnableDesc') }}

-
- -
-