diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1c34c670..47b26c5f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -139,126 +139,9 @@ jobs: if-no-files-found: error retention-days: 7 - android: - runs-on: ubuntu-22.04 - needs: frontend - timeout-minutes: 240 - steps: - - uses: actions/checkout@v4 - - - name: Download frontend dist - uses: actions/download-artifact@v4 - with: - name: web-dist - path: web/dist - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Cache Android Docker layers - uses: actions/cache@v4 - with: - path: /tmp/.buildx-cache/android - key: android-buildx-${{ runner.os }}-${{ hashFiles('build/cross/Dockerfile.android') }} - restore-keys: | - android-buildx-${{ runner.os }}- - - - name: Build Android Docker image - uses: docker/build-push-action@v6 - with: - context: build/cross - file: build/cross/Dockerfile.android - tags: one-kvm-android-build:ci - load: true - cache-from: type=local,src=/tmp/.buildx-cache/android - cache-to: type=local,dest=/tmp/.buildx-cache/android-new,mode=max - - - name: Rotate Android Docker layer cache - run: | - rm -rf /tmp/.buildx-cache/android - mv /tmp/.buildx-cache/android-new /tmp/.buildx-cache/android - - - name: Cache Android build dependencies - uses: actions/cache@v4 - with: - path: | - .github/android-cache/gradle - .github/android-cache/cargo-registry - .github/android-cache/cargo-git - .tmp/android-ffmpeg-check - .tmp/android-turbojpeg-src - .tmp/android-libyuv-src - .tmp/android-alsa-src - .tmp/android-opus-src - dist/android-ffmpeg-mediacodec - dist/android-turbojpeg - dist/android-libyuv - dist/android-alsa - dist/android-opus - key: android-deps-${{ runner.os }}-${{ hashFiles('android/**/*.gradle.kts', 'android/gradle/wrapper/gradle-wrapper.properties', 'android/native/Cargo.lock', 'Cargo.lock', 'scripts/build-android-*.sh') }} - restore-keys: | - android-deps-${{ runner.os }}- - - - name: Prepare Android FFmpeg source - run: | - chmod +x android/gradlew - if [ ! -x .tmp/android-ffmpeg-check/src/ffmpeg-rockchip/configure ]; then - rm -rf .tmp/android-ffmpeg-check/src - mkdir -p .tmp/android-ffmpeg-check/src - wget -q https://files.mofeng.run/src/image/other/ffmpeg.tar.gz -O .tmp/android-ffmpeg-check/ffmpeg.tar.gz - tar -xzf .tmp/android-ffmpeg-check/ffmpeg.tar.gz -C .tmp/android-ffmpeg-check/src --strip-components=1 - fi - - - name: Build Android APK - env: - ONE_KVM_ANDROID_DOCKER_IMAGE: one-kvm-android-build:ci - ONE_KVM_ANDROID_SKIP_DOCKER_BUILD: "1" - ONE_KVM_ANDROID_GRADLE_CACHE_DIR: ${{ github.workspace }}/.github/android-cache/gradle - ONE_KVM_ANDROID_CARGO_REGISTRY_CACHE_DIR: ${{ github.workspace }}/.github/android-cache/cargo-registry - ONE_KVM_ANDROID_CARGO_GIT_CACHE_DIR: ${{ github.workspace }}/.github/android-cache/cargo-git - run: bash build/build-android.sh all - - - name: Fix Android build permissions - if: ${{ always() }} - run: | - paths=( - .github/android-cache - .tmp/android-ffmpeg-check - .tmp/android-turbojpeg-src - .tmp/android-libyuv-src - .tmp/android-alsa-src - .tmp/android-opus-src - dist/android-ffmpeg-mediacodec - dist/android-turbojpeg - dist/android-libyuv - dist/android-alsa - dist/android-opus - target/android - android/.gradle - android/app/build - android/native/target - ) - existing=() - for path in "${paths[@]}"; do - if [ -e "$path" ]; then - existing+=("$path") - fi - done - if [ "${#existing[@]}" -gt 0 ]; then - sudo chown -R "$USER:$USER" "${existing[@]}" - fi - - - name: Upload Android APK - uses: actions/upload-artifact@v4 - with: - name: one-kvm-android-apk - path: target/android/one-kvm_*.apk - if-no-files-found: error - retention-days: 7 - release: runs-on: ubuntu-22.04 - needs: [deb, windows, android] + needs: [deb, windows] if: ${{ github.event_name == 'workflow_dispatch' && inputs.publish_release }} timeout-minutes: 30 permissions: @@ -283,12 +166,6 @@ jobs: name: one-kvm-windows-exe path: release-artifacts/windows - - name: Download Android artifact - uses: actions/download-artifact@v4 - with: - name: one-kvm-android-apk - path: release-artifacts/android - - name: Publish GitHub Release uses: softprops/action-gh-release@v2 with: @@ -298,4 +175,3 @@ jobs: files: | release-artifacts/deb/*.deb release-artifacts/windows/*.exe - release-artifacts/android/*.apk diff --git a/Cargo.toml b/Cargo.toml index c0596177..23d8439b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "one-kvm" -version = "0.2.3" +version = "0.2.4" edition = "2021" authors = ["SilentWind"] description = "A open and lightweight IP-KVM solution written in Rust" diff --git a/build.rs b/build.rs index 3408703e..ffc2fdd1 100644 --- a/build.rs +++ b/build.rs @@ -2,21 +2,6 @@ use std::fs; use std::path::Path; fn main() { - // Set BUILD_DATE environment variable for compile-time access - // Use system time to avoid adding chrono as a build dependency - let now = std::time::SystemTime::now(); - let duration = now.duration_since(std::time::UNIX_EPOCH).unwrap(); - let secs = duration.as_secs(); - - // Convert Unix timestamp to date (simplified calculation) - // Days since epoch - let days = secs / 86400; - // Calculate year, month, day from days since 1970-01-01 - let (year, month, day) = days_to_ymd(days as i64); - let build_date = format!("{:04}-{:02}-{:02}", year, month, day); - - println!("cargo:rustc-env=BUILD_DATE={}", build_date); - // Compile protobuf files for RustDesk protocol compile_protos(); @@ -86,19 +71,3 @@ pub mod rustdesk { fs::write(&dest_path, code).expect("Failed to write secrets_generated.rs"); } - -/// Convert days since Unix epoch to year-month-day -fn days_to_ymd(days: i64) -> (i32, u32, u32) { - // Algorithm from http://howardhinnant.github.io/date_algorithms.html - let z = days + 719468; - let era = if z >= 0 { z } else { z - 146096 } / 146097; - let doe = (z - era * 146097) as u32; - let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; - let y = yoe as i64 + era * 400; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let d = doy - (153 * mp + 2) / 5 + 1; - let m = if mp < 10 { mp + 3 } else { mp - 9 }; - let year = if m <= 2 { y + 1 } else { y }; - (year as i32, m, d) -} 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" \ diff --git a/libs/ventoy-img-rs/src/exfat/format.rs b/libs/ventoy-img-rs/src/exfat/format.rs index f0717e0d..f8aba5b3 100644 --- a/libs/ventoy-img-rs/src/exfat/format.rs +++ b/libs/ventoy-img-rs/src/exfat/format.rs @@ -97,14 +97,14 @@ impl ExfatBootSector { let heap_sectors = volume_length as u32 - cluster_heap_offset; let cluster_count = heap_sectors / sectors_per_cluster; - // Calculate root directory cluster based on upcase table size - // Cluster 2: Bitmap (1 cluster) - // Cluster 3...: Upcase table (128KB, may span multiple clusters) - // Next available: Root directory + // Calculate root directory cluster based on bitmap and upcase table size. const UPCASE_TABLE_SIZE: u64 = 128 * 1024; + let bitmap_size = ((cluster_count + 7) / 8) as u64; + let bitmap_clusters = + ((bitmap_size + cluster_size as u64 - 1) / cluster_size as u64).max(1) as u32; let upcase_clusters = ((UPCASE_TABLE_SIZE + cluster_size as u64 - 1) / cluster_size as u64) as u32; - let first_cluster_of_root = 3 + upcase_clusters; + let first_cluster_of_root = 2 + bitmap_clusters + upcase_clusters; Self { jump_boot: [0xEB, 0x76, 0x90], @@ -211,6 +211,15 @@ const ENTRY_TYPE_VOLUME_LABEL: u8 = 0x83; const ENTRY_TYPE_BITMAP: u8 = 0x81; const ENTRY_TYPE_UPCASE: u8 = 0x82; +fn set_cluster_allocated(bitmap: &mut [u8], cluster: u32) { + let index = (cluster - 2) as usize; + let byte_idx = index / 8; + let bit_idx = index % 8; + if byte_idx < bitmap.len() { + bitmap[byte_idx] |= 1 << bit_idx; + } +} + /// Create volume label directory entry fn create_volume_label_entry(label: &str) -> [u8; 32] { let mut entry = [0u8; 32]; @@ -301,27 +310,42 @@ pub fn format_exfat( let fat_offset = partition_offset + boot_sector.fat_offset as u64 * 512; writer.seek(SeekFrom::Start(fat_offset))?; + let bitmap_size = (boot_sector.cluster_count + 7) / 8; + let bitmap_clusters = + ((bitmap_size as u64 + cluster_size as u64 - 1) / cluster_size as u64).max(1) as u32; + // Calculate how many clusters the upcase table needs (128KB) const UPCASE_TABLE_SIZE: u64 = 128 * 1024; let upcase_clusters = ((UPCASE_TABLE_SIZE + cluster_size as u64 - 1) / cluster_size as u64) as u32; - let root_cluster = 3 + upcase_clusters; // Root comes after bitmap and upcase + let bitmap_start_cluster = 2; + let upcase_start_cluster = bitmap_start_cluster + bitmap_clusters; + let root_cluster = upcase_start_cluster + upcase_clusters; // FAT entries: cluster 0 and 1 are reserved // 0: Media type (0xFFFFFFF8) // 1: Reserved (0xFFFFFFFF) - // 2: Bitmap cluster (single cluster, end of chain) - // 3..3+upcase_clusters-1: Upcase table cluster chain - // 3+upcase_clusters: Root directory cluster (end of chain) + // 2..2+bitmap_clusters-1: Bitmap cluster chain + // upcase_start_cluster..upcase_start_cluster+upcase_clusters-1: Upcase table cluster chain + // root_cluster: Root directory cluster (end of chain) let mut fat_entries = vec![ 0xFFFFFFF8, // Media type 0xFFFFFFFF, // Reserved - 0xFFFFFFFF, // Bitmap (single cluster, end of chain) ]; + // Build allocation bitmap cluster chain + for i in 0..bitmap_clusters { + let cluster_num = bitmap_start_cluster + i; + if i == bitmap_clusters - 1 { + fat_entries.push(0xFFFFFFFF); + } else { + fat_entries.push(cluster_num + 1); + } + } + // Build upcase table cluster chain for i in 0..upcase_clusters { - let cluster_num = 3 + i; + let cluster_num = upcase_start_cluster + i; if i == upcase_clusters - 1 { // Last cluster in chain fat_entries.push(0xFFFFFFFF); @@ -345,38 +369,26 @@ pub fn format_exfat( // Calculate cluster heap offset let heap_offset = partition_offset + boot_sector.cluster_heap_offset as u64 * 512; - // Cluster 2: Allocation Bitmap - let bitmap_size = (boot_sector.cluster_count + 7) / 8; - let _bitmap_clusters = - ((bitmap_size as u64 + cluster_size as u64 - 1) / cluster_size as u64).max(1); - let mut bitmap = vec![0u8; cluster_size as usize]; + // Allocation Bitmap + let mut bitmap = vec![0u8; bitmap_clusters as usize * cluster_size as usize]; - // Mark clusters 2, 3..3+upcase_clusters-1, root_cluster as used - // Cluster 2: bitmap - bitmap[0] |= 0b00000100; // Bit 2 - // Clusters 3..3+upcase_clusters-1: upcase table + // Mark bitmap, upcase, and root directory clusters as used. + // exFAT allocation bitmap bit 0 describes cluster 2. + for i in 0..bitmap_clusters { + set_cluster_allocated(&mut bitmap, bitmap_start_cluster + i); + } for i in 0..upcase_clusters { - let cluster = 3 + i; - let byte_idx = (cluster / 8) as usize; - let bit_idx = cluster % 8; - if byte_idx < bitmap.len() { - bitmap[byte_idx] |= 1 << bit_idx; - } - } - // Root directory cluster - let byte_idx = (root_cluster / 8) as usize; - let bit_idx = root_cluster % 8; - if byte_idx < bitmap.len() { - bitmap[byte_idx] |= 1 << bit_idx; + set_cluster_allocated(&mut bitmap, upcase_start_cluster + i); } + set_cluster_allocated(&mut bitmap, root_cluster); writer.seek(SeekFrom::Start(heap_offset))?; writer.write_all(&bitmap)?; - // Cluster 3..3+upcase_clusters-1: Upcase table + // Upcase table let upcase_data = generate_upcase_table(); let upcase_checksum = calculate_upcase_checksum(&upcase_data); - let upcase_offset = heap_offset + cluster_size as u64; // Start at cluster 3 + let upcase_offset = heap_offset + bitmap_clusters as u64 * cluster_size as u64; writer.seek(SeekFrom::Start(upcase_offset))?; writer.write_all(&upcase_data)?; @@ -388,13 +400,18 @@ pub fn format_exfat( } // Root directory cluster - let root_offset = heap_offset + (1 + upcase_clusters as u64) * cluster_size as u64; + let root_offset = + heap_offset + (bitmap_clusters as u64 + upcase_clusters as u64) * cluster_size as u64; writer.seek(SeekFrom::Start(root_offset))?; // Write directory entries let volume_label_entry = create_volume_label_entry(label); - let bitmap_entry = create_bitmap_entry(2, bitmap_size as u64); - let upcase_entry = create_upcase_entry(3, upcase_data.len() as u64, upcase_checksum); + let bitmap_entry = create_bitmap_entry(bitmap_start_cluster, bitmap_size as u64); + let upcase_entry = create_upcase_entry( + upcase_start_cluster, + upcase_data.len() as u64, + upcase_checksum, + ); writer.write_all(&volume_label_entry)?; writer.write_all(&bitmap_entry)?; @@ -413,6 +430,9 @@ pub fn format_exfat( #[cfg(test)] mod tests { use super::*; + use crate::partition::PartitionLayout; + use std::io::Read; + use tempfile::NamedTempFile; #[test] fn test_cluster_size() { @@ -428,4 +448,120 @@ mod tests { assert_eq!(get_cluster_size(8 * 1024 * 1024 * 1024 / 512), 131072); // 8GB → 128KB assert_eq!(get_cluster_size(16 * 1024 * 1024 * 1024 / 512), 131072); // 16GB → 128KB } + + #[test] + fn test_format_bitmap_uses_cluster_heap_bit_indices() { + let temp_file = NamedTempFile::new().unwrap(); + let path = temp_file.path(); + let size = 64 * 1024 * 1024u64; + let layout = PartitionLayout::calculate(size).unwrap(); + + let mut file = std::fs::OpenOptions::new() + .read(true) + .write(true) + .open(path) + .unwrap(); + file.set_len(size).unwrap(); + format_exfat(&mut file, layout.data_offset(), layout.data_size(), "TEST").unwrap(); + + let mut boot_sector = [0u8; 512]; + file.seek(SeekFrom::Start(layout.data_offset())).unwrap(); + file.read_exact(&mut boot_sector).unwrap(); + let cluster_heap_offset = u32::from_le_bytes(boot_sector[88..92].try_into().unwrap()); + let first_cluster_of_root = u32::from_le_bytes(boot_sector[96..100].try_into().unwrap()); + let sectors_per_cluster = 1u64 << boot_sector[109]; + let cluster_size = sectors_per_cluster * 512; + + let bitmap_offset = layout.data_offset() + cluster_heap_offset as u64 * 512; + let mut bitmap = vec![0u8; cluster_size as usize]; + file.seek(SeekFrom::Start(bitmap_offset)).unwrap(); + file.read_exact(&mut bitmap).unwrap(); + + let is_allocated = |cluster: u32| { + let index = (cluster - 2) as usize; + (bitmap[index / 8] & (1 << (index % 8))) != 0 + }; + + assert!( + is_allocated(2), + "allocation bitmap cluster must be allocated" + ); + assert!( + is_allocated(3), + "upcase table first cluster must be allocated" + ); + assert!( + is_allocated(first_cluster_of_root), + "root directory cluster must be allocated" + ); + assert!( + !is_allocated(first_cluster_of_root + 1), + "first data cluster after root should be free after formatting" + ); + } + + #[test] + fn test_format_supports_multi_cluster_allocation_bitmap() { + let temp_file = NamedTempFile::new().unwrap(); + let path = temp_file.path(); + let size = 240 * 1024 * 1024u64; + let layout = PartitionLayout::calculate(size).unwrap(); + + let mut file = std::fs::OpenOptions::new() + .read(true) + .write(true) + .open(path) + .unwrap(); + file.set_len(size).unwrap(); + format_exfat(&mut file, layout.data_offset(), layout.data_size(), "TEST").unwrap(); + + let mut boot_sector = [0u8; 512]; + file.seek(SeekFrom::Start(layout.data_offset())).unwrap(); + file.read_exact(&mut boot_sector).unwrap(); + let fat_offset = u32::from_le_bytes(boot_sector[80..84].try_into().unwrap()); + let cluster_heap_offset = u32::from_le_bytes(boot_sector[88..92].try_into().unwrap()); + let cluster_count = u32::from_le_bytes(boot_sector[92..96].try_into().unwrap()); + let first_cluster_of_root = u32::from_le_bytes(boot_sector[96..100].try_into().unwrap()); + let sectors_per_cluster = 1u64 << boot_sector[109]; + let cluster_size = sectors_per_cluster * 512; + + let bitmap_size = ((cluster_count + 7) / 8) as u64; + let bitmap_clusters = bitmap_size.div_ceil(cluster_size) as u32; + assert!( + bitmap_clusters > 1, + "test volume should require a multi-cluster allocation bitmap" + ); + + let upcase_clusters = (128 * 1024u64).div_ceil(cluster_size) as u32; + assert_eq!(first_cluster_of_root, 2 + bitmap_clusters + upcase_clusters); + + let read_fat = |file: &mut std::fs::File, cluster: u32| -> u32 { + let offset = layout.data_offset() + fat_offset as u64 * 512 + cluster as u64 * 4; + let mut bytes = [0u8; 4]; + file.seek(SeekFrom::Start(offset)).unwrap(); + file.read_exact(&mut bytes).unwrap(); + u32::from_le_bytes(bytes) + }; + assert_eq!(read_fat(&mut file, 2), 3); + assert_eq!(read_fat(&mut file, 2 + bitmap_clusters - 1), 0xFFFFFFFF); + + let root_offset = layout.data_offset() + + cluster_heap_offset as u64 * 512 + + (first_cluster_of_root - 2) as u64 * cluster_size; + let mut root = vec![0u8; cluster_size as usize]; + file.seek(SeekFrom::Start(root_offset)).unwrap(); + file.read_exact(&mut root).unwrap(); + + assert_eq!(root[32], ENTRY_TYPE_BITMAP); + assert_eq!(u32::from_le_bytes(root[52..56].try_into().unwrap()), 2); + assert_eq!( + u64::from_le_bytes(root[56..64].try_into().unwrap()), + bitmap_size + ); + assert_eq!(root[64], ENTRY_TYPE_UPCASE); + assert_eq!( + u32::from_le_bytes(root[84..88].try_into().unwrap()), + 2 + bitmap_clusters + ); + } } diff --git a/libs/ventoy-img-rs/src/exfat/ops.rs b/libs/ventoy-img-rs/src/exfat/ops.rs index b05ba956..ebe79040 100644 --- a/libs/ventoy-img-rs/src/exfat/ops.rs +++ b/libs/ventoy-img-rs/src/exfat/ops.rs @@ -14,9 +14,13 @@ use std::path::Path; /// FAT entry values const FAT_ENTRY_FREE: u32 = 0x00000000; const FAT_ENTRY_END_OF_CHAIN: u32 = 0xFFFFFFFF; +const VOLUME_DIRTY_FLAG: u16 = 0x0002; +const VOLUME_FLAGS_OFFSET: u64 = 106; /// Directory entry types const ENTRY_TYPE_END: u8 = 0x00; +const ENTRY_TYPE_BITMAP: u8 = 0x81; +const ENTRY_TYPE_UPCASE: u8 = 0x82; const ENTRY_TYPE_FILE: u8 = 0x85; const ENTRY_TYPE_STREAM: u8 = 0xC0; const ENTRY_TYPE_FILE_NAME: u8 = 0xC1; @@ -137,6 +141,11 @@ pub struct ExfatFs { cluster_heap_offset: u32, cluster_count: u32, first_cluster_of_root: u32, + allocation_bitmap_first_cluster: u32, + allocation_bitmap_size: u64, + upcase_table_first_cluster: u32, + upcase_table_size: u64, + upcase_table_checksum: u32, // Performance caches /// FAT table segment cache fat_cache: FatCache, @@ -182,7 +191,7 @@ impl ExfatFs { let sectors_per_cluster = 1u32 << sectors_per_cluster_shift; let cluster_size = bytes_per_sector * sectors_per_cluster; - Ok(Self { + let mut fs = Self { file, partition_offset, bytes_per_sector, @@ -193,11 +202,151 @@ impl ExfatFs { cluster_heap_offset, cluster_count, first_cluster_of_root, + allocation_bitmap_first_cluster: 2, + allocation_bitmap_size: ((cluster_count + 7) / 8) as u64, + upcase_table_first_cluster: 0, + upcase_table_size: 0, + upcase_table_checksum: 0, // Initialize caches fat_cache: FatCache::new(), bitmap_cache: None, bitmap_dirty: false, - }) + }; + fs.discover_root_metadata_entries()?; + Ok(fs) + } + + fn discover_root_metadata_entries(&mut self) -> Result<()> { + let mut bitmap_found = false; + let mut upcase_found = false; + let root_clusters = self.read_cluster_chain(self.first_cluster_of_root)?; + + 'outer: for &cluster in &root_clusters { + let cluster_data = self.read_cluster(cluster)?; + let mut i = 0; + + while i + 32 <= cluster_data.len() { + let entry_type = cluster_data[i]; + match entry_type { + ENTRY_TYPE_END => break 'outer, + ENTRY_TYPE_BITMAP => { + let first_cluster = + u32::from_le_bytes(cluster_data[i + 20..i + 24].try_into().unwrap()); + let size = + u64::from_le_bytes(cluster_data[i + 24..i + 32].try_into().unwrap()); + if first_cluster < 2 || size == 0 { + return Err(VentoyError::FilesystemError( + "Invalid exFAT allocation bitmap entry".to_string(), + )); + } + self.allocation_bitmap_first_cluster = first_cluster; + self.allocation_bitmap_size = size; + bitmap_found = true; + } + ENTRY_TYPE_UPCASE => { + let checksum = + u32::from_le_bytes(cluster_data[i + 4..i + 8].try_into().unwrap()); + let first_cluster = + u32::from_le_bytes(cluster_data[i + 20..i + 24].try_into().unwrap()); + let size = + u64::from_le_bytes(cluster_data[i + 24..i + 32].try_into().unwrap()); + if first_cluster < 2 || size == 0 { + return Err(VentoyError::FilesystemError( + "Invalid exFAT upcase table entry".to_string(), + )); + } + self.upcase_table_first_cluster = first_cluster; + self.upcase_table_size = size; + self.upcase_table_checksum = checksum; + upcase_found = true; + } + ENTRY_TYPE_FILE => { + let secondary_count = cluster_data[i + 1] as usize; + i += (1 + secondary_count) * 32; + continue; + } + _ => {} + } + + i += 32; + } + } + + if !bitmap_found { + return Err(VentoyError::FilesystemError( + "exFAT allocation bitmap entry not found".to_string(), + )); + } + if !upcase_found { + return Err(VentoyError::FilesystemError( + "exFAT upcase table entry not found".to_string(), + )); + } + + let min_bitmap_size = ((self.cluster_count + 7) / 8) as u64; + if self.allocation_bitmap_size < min_bitmap_size { + return Err(VentoyError::FilesystemError(format!( + "exFAT allocation bitmap too small: {} bytes, need at least {}", + self.allocation_bitmap_size, min_bitmap_size + ))); + } + + Ok(()) + } + + fn volume_flags_offset(&self) -> u64 { + self.partition_offset + VOLUME_FLAGS_OFFSET + } + + fn read_volume_flags(&mut self) -> Result { + let mut bytes = [0u8; 2]; + self.file + .seek(SeekFrom::Start(self.volume_flags_offset()))?; + self.file.read_exact(&mut bytes)?; + Ok(u16::from_le_bytes(bytes)) + } + + fn is_volume_dirty(&mut self) -> Result { + Ok((self.read_volume_flags()? & VOLUME_DIRTY_FLAG) != 0) + } + + fn set_volume_dirty(&mut self, dirty: bool) -> Result<()> { + let mut flags = self.read_volume_flags()?; + if dirty { + flags |= VOLUME_DIRTY_FLAG; + } else { + flags &= !VOLUME_DIRTY_FLAG; + } + + self.file + .seek(SeekFrom::Start(self.volume_flags_offset()))?; + self.file.write_all(&flags.to_le_bytes())?; + self.file.flush()?; + Ok(()) + } + + fn begin_write_transaction(&mut self) -> Result { + let was_dirty = self.is_volume_dirty()?; + if !was_dirty { + self.set_volume_dirty(true)?; + } + Ok(was_dirty) + } + + fn finish_write_transaction(&mut self, was_dirty: bool, result: Result) -> Result { + match result { + Ok(value) => { + self.file.flush()?; + if !was_dirty { + self.set_volume_dirty(false)?; + } + Ok(value) + } + Err(err) => { + let _ = self.file.flush(); + Err(err) + } + } } // ==================== Cluster I/O Operations ==================== @@ -328,13 +477,89 @@ impl ExfatFs { // ==================== Allocation Bitmap Operations ==================== + fn read_cluster_chain_bytes(&mut self, first_cluster: u32, byte_len: u64) -> Result> { + let chain = self.read_cluster_chain(first_cluster)?; + if chain.is_empty() { + return Err(VentoyError::FilesystemError( + "Empty cluster chain".to_string(), + )); + } + + let capacity = byte_len.min(chain.len() as u64 * self.cluster_size as u64) as usize; + let mut data = Vec::with_capacity(capacity); + for &cluster in &chain { + let cluster_data = self.read_cluster(cluster)?; + data.extend_from_slice(&cluster_data); + if data.len() >= byte_len as usize { + data.truncate(byte_len as usize); + break; + } + } + + if data.len() < byte_len as usize { + return Err(VentoyError::FilesystemError(format!( + "Cluster chain for {} is shorter than expected: {} < {} bytes", + first_cluster, + data.len(), + byte_len + ))); + } + + Ok(data) + } + + fn write_cluster_chain_bytes( + &mut self, + first_cluster: u32, + byte_len: u64, + data: &[u8], + ) -> Result<()> { + if data.len() < byte_len as usize { + return Err(VentoyError::FilesystemError(format!( + "Not enough data to write cluster chain: {} < {} bytes", + data.len(), + byte_len + ))); + } + + let chain = self.read_cluster_chain(first_cluster)?; + if chain.is_empty() { + return Err(VentoyError::FilesystemError( + "Empty cluster chain".to_string(), + )); + } + + let mut bytes_written = 0usize; + let bytes_to_write = byte_len as usize; + for &cluster in &chain { + let end = (bytes_written + self.cluster_size as usize).min(bytes_to_write); + if bytes_written >= end { + break; + } + self.write_cluster(cluster, &data[bytes_written..end])?; + bytes_written = end; + } + + if bytes_written < bytes_to_write { + return Err(VentoyError::FilesystemError(format!( + "Cluster chain for {} is shorter than expected: {} < {} bytes", + first_cluster, bytes_written, bytes_to_write + ))); + } + + Ok(()) + } + /// Read the allocation bitmap (with caching) fn read_bitmap(&mut self) -> Result> { if let Some(ref bitmap) = self.bitmap_cache { return Ok(bitmap.clone()); } - let bitmap = self.read_cluster(2)?; + let bitmap = self.read_cluster_chain_bytes( + self.allocation_bitmap_first_cluster, + self.allocation_bitmap_size, + )?; self.bitmap_cache = Some(bitmap.clone()); Ok(bitmap) } @@ -342,7 +567,10 @@ impl ExfatFs { /// Get a mutable reference to the cached bitmap, loading if necessary fn get_bitmap_mut(&mut self) -> Result<&mut Vec> { if self.bitmap_cache.is_none() { - let bitmap = self.read_cluster(2)?; + let bitmap = self.read_cluster_chain_bytes( + self.allocation_bitmap_first_cluster, + self.allocation_bitmap_size, + )?; self.bitmap_cache = Some(bitmap); } Ok(self.bitmap_cache.as_mut().unwrap()) @@ -351,7 +579,11 @@ impl ExfatFs { /// Write the allocation bitmap (with cache management) #[allow(dead_code)] fn write_bitmap(&mut self, bitmap: &[u8]) -> Result<()> { - self.write_cluster(2, bitmap)?; + self.write_cluster_chain_bytes( + self.allocation_bitmap_first_cluster, + self.allocation_bitmap_size, + bitmap, + )?; self.bitmap_cache = Some(bitmap.to_vec()); self.bitmap_dirty = false; Ok(()) @@ -362,7 +594,11 @@ impl ExfatFs { fn flush_bitmap(&mut self) -> Result<()> { if self.bitmap_dirty { if let Some(bitmap) = self.bitmap_cache.take() { - self.write_cluster(2, &bitmap)?; + self.write_cluster_chain_bytes( + self.allocation_bitmap_first_cluster, + self.allocation_bitmap_size, + &bitmap, + )?; self.bitmap_cache = Some(bitmap); self.bitmap_dirty = false; } @@ -400,10 +636,7 @@ impl ExfatFs { let bitmap = self.read_bitmap()?; let mut free_clusters = Vec::with_capacity(count); - // Start from cluster after root directory - // (root is at first_cluster_of_root, which varies based on cluster size) - let start_cluster = self.first_cluster_of_root + 1; - for cluster in start_cluster..self.cluster_count + 2 { + for cluster in 2..self.cluster_count + 2 { if !Self::is_cluster_allocated(&bitmap, cluster) { free_clusters.push(cluster); if free_clusters.len() >= count { @@ -482,7 +715,11 @@ impl ExfatFs { /// Flush bitmap to disk immediately fn flush_bitmap_now(&mut self) -> Result<()> { if let Some(bitmap) = self.bitmap_cache.take() { - self.write_cluster(2, &bitmap)?; + self.write_cluster_chain_bytes( + self.allocation_bitmap_first_cluster, + self.allocation_bitmap_size, + &bitmap, + )?; self.bitmap_cache = Some(bitmap); } Ok(()) @@ -823,10 +1060,12 @@ impl ExfatFs { // Exclude the newly added cluster let mut cluster_data = self.read_cluster(cluster)?; - // Scan for END markers and replace them with 0xFF (invalid entry, will be skipped) + // Scan for END markers and replace them with inactive entries. Leaving an + // END marker before later directory clusters makes hosts stop early; using + // an in-use invalid type can make strict hosts treat the directory as bad. for i in (0..cluster_data.len()).step_by(32) { if cluster_data[i] == ENTRY_TYPE_END { - cluster_data[i] = 0xFF; // Invalid entry type + cluster_data[i] = ENTRY_TYPE_DELETED_FILE; } } @@ -905,8 +1144,16 @@ impl ExfatFs { let empty_cluster = vec![0u8; self.cluster_size as usize]; self.write_cluster(dir_cluster, &empty_cluster)?; - // Create directory entry in parent - self.create_entry_in_directory(parent_cluster, name, dir_cluster, 0, true)?; + // exFAT directories have allocated data. A zero-length directory stream + // makes Windows treat the directory as corrupt even when the cluster + // chain and child entries are otherwise valid. + self.create_entry_in_directory( + parent_cluster, + name, + dir_cluster, + self.cluster_size as u64, + true, + )?; self.file.flush()?; Ok(dir_cluster) @@ -1120,46 +1367,54 @@ impl ExfatFs { /// Write a file to the filesystem (root directory, no overwrite) pub fn write_file(&mut self, name: &str, data: &[u8]) -> Result<()> { - // Validate filename - if name.is_empty() || name.len() > 255 { - return Err(VentoyError::FilesystemError( - "Invalid filename length".to_string(), - )); - } + let was_dirty = self.begin_write_transaction()?; + let result = (|| { + // Validate filename + if name.is_empty() || name.len() > 255 { + return Err(VentoyError::FilesystemError( + "Invalid filename length".to_string(), + )); + } - // Check if file already exists - if self.find_file_entry(name)?.is_some() { - return Err(VentoyError::FilesystemError(format!( - "File '{}' already exists", - name - ))); - } - - self.write_file_data_and_entry(self.first_cluster_of_root, name, data) - } - - /// Write a file to the filesystem with overwrite option - pub fn write_file_overwrite(&mut self, name: &str, data: &[u8], overwrite: bool) -> Result<()> { - // Validate filename - if name.is_empty() || name.len() > 255 { - return Err(VentoyError::FilesystemError( - "Invalid filename length".to_string(), - )); - } - - // Check if file already exists - if self.find_file_entry(name)?.is_some() { - if overwrite { - self.delete_file(name)?; - } else { + // Check if file already exists + if self.find_file_entry(name)?.is_some() { return Err(VentoyError::FilesystemError(format!( "File '{}' already exists", name ))); } - } - self.write_file_data_and_entry(self.first_cluster_of_root, name, data) + self.write_file_data_and_entry(self.first_cluster_of_root, name, data) + })(); + self.finish_write_transaction(was_dirty, result) + } + + /// Write a file to the filesystem with overwrite option + pub fn write_file_overwrite(&mut self, name: &str, data: &[u8], overwrite: bool) -> Result<()> { + let was_dirty = self.begin_write_transaction()?; + let result = (|| { + // Validate filename + if name.is_empty() || name.len() > 255 { + return Err(VentoyError::FilesystemError( + "Invalid filename length".to_string(), + )); + } + + // Check if file already exists + if self.find_file_entry(name)?.is_some() { + if overwrite { + self.delete_file(name)?; + } else { + return Err(VentoyError::FilesystemError(format!( + "File '{}' already exists", + name + ))); + } + } + + self.write_file_data_and_entry(self.first_cluster_of_root, name, data) + })(); + self.finish_write_transaction(was_dirty, result) } /// Write a file to a specific path @@ -1174,38 +1429,42 @@ impl ExfatFs { create_parents: bool, overwrite: bool, ) -> Result<()> { - let resolved = self.resolve_path(path, create_parents)?; + let was_dirty = self.begin_write_transaction()?; + let result = (|| { + let resolved = self.resolve_path(path, create_parents)?; - // Validate filename - if resolved.name.is_empty() || resolved.name.len() > 255 { - return Err(VentoyError::FilesystemError( - "Invalid filename length".to_string(), - )); - } - - // Handle existing file - if let Some(location) = resolved.location { - if location.is_directory { - return Err(VentoyError::FilesystemError(format!( - "'{}' is a directory", - path - ))); + // Validate filename + if resolved.name.is_empty() || resolved.name.len() > 255 { + return Err(VentoyError::FilesystemError( + "Invalid filename length".to_string(), + )); } - if overwrite { - // Delete existing file - if location.first_cluster >= 2 { - self.free_cluster_chain(location.first_cluster)?; + + // Handle existing file + if let Some(location) = resolved.location { + if location.is_directory { + return Err(VentoyError::FilesystemError(format!( + "'{}' is a directory", + path + ))); + } + if overwrite { + // Delete existing file + if location.first_cluster >= 2 { + self.free_cluster_chain(location.first_cluster)?; + } + self.delete_file_entry(&location)?; + } else { + return Err(VentoyError::FilesystemError(format!( + "File '{}' already exists", + path + ))); } - self.delete_file_entry(&location)?; - } else { - return Err(VentoyError::FilesystemError(format!( - "File '{}' already exists", - path - ))); } - } - self.write_file_data_and_entry(resolved.parent_cluster, &resolved.name, data) + self.write_file_data_and_entry(resolved.parent_cluster, &resolved.name, data) + })(); + self.finish_write_transaction(was_dirty, result) } /// Read file data from a location @@ -1276,99 +1535,115 @@ impl ExfatFs { /// Delete a file from the filesystem (root directory) pub fn delete_file(&mut self, name: &str) -> Result<()> { - let location = self - .find_file_entry(name)? - .ok_or_else(|| VentoyError::FilesystemError(format!("File '{}' not found", name)))?; + let was_dirty = self.begin_write_transaction()?; + let result = (|| { + let location = self.find_file_entry(name)?.ok_or_else(|| { + VentoyError::FilesystemError(format!("File '{}' not found", name)) + })?; - // Free cluster chain - if location.first_cluster >= 2 { - self.free_cluster_chain(location.first_cluster)?; - } + // Free cluster chain + if location.first_cluster >= 2 { + self.free_cluster_chain(location.first_cluster)?; + } - // Delete directory entry - self.delete_file_entry(&location)?; + // Delete directory entry + self.delete_file_entry(&location)?; - self.file.flush()?; - Ok(()) + self.file.flush()?; + Ok(()) + })(); + self.finish_write_transaction(was_dirty, result) } /// Delete a file or directory at a specific path pub fn delete_path(&mut self, path: &str) -> Result<()> { - let resolved = self.resolve_path(path, false)?; + let was_dirty = self.begin_write_transaction()?; + let result = (|| { + let resolved = self.resolve_path(path, false)?; - let location = resolved - .location - .ok_or_else(|| VentoyError::FilesystemError(format!("'{}' not found", path)))?; + let location = resolved + .location + .ok_or_else(|| VentoyError::FilesystemError(format!("'{}' not found", path)))?; - // If it's a directory, check if it's empty - if location.is_directory { - let contents = self.list_files_in_directory(location.first_cluster, "")?; - if !contents.is_empty() { - return Err(VentoyError::FilesystemError(format!( - "Directory '{}' is not empty", - path - ))); + // If it's a directory, check if it's empty + if location.is_directory { + let contents = self.list_files_in_directory(location.first_cluster, "")?; + if !contents.is_empty() { + return Err(VentoyError::FilesystemError(format!( + "Directory '{}' is not empty", + path + ))); + } } - } - // Free cluster chain - if location.first_cluster >= 2 { - self.free_cluster_chain(location.first_cluster)?; - } + // Free cluster chain + if location.first_cluster >= 2 { + self.free_cluster_chain(location.first_cluster)?; + } - // Delete directory entry - self.delete_file_entry(&location)?; + // Delete directory entry + self.delete_file_entry(&location)?; - self.file.flush()?; - Ok(()) + self.file.flush()?; + Ok(()) + })(); + self.finish_write_transaction(was_dirty, result) } /// Delete a directory and all its contents recursively pub fn delete_recursive(&mut self, path: &str) -> Result<()> { - let resolved = self.resolve_path(path, false)?; + let was_dirty = self.begin_write_transaction()?; + let result = (|| { + let resolved = self.resolve_path(path, false)?; - let location = resolved - .location - .ok_or_else(|| VentoyError::FilesystemError(format!("'{}' not found", path)))?; + let location = resolved + .location + .ok_or_else(|| VentoyError::FilesystemError(format!("'{}' not found", path)))?; - if location.is_directory { - // Get all contents and delete them first - let contents = self.list_files_in_directory(location.first_cluster, "")?; - for item in contents { - let item_path = if path.ends_with('/') { - format!("{}{}", path, item.name) - } else { - format!("{}/{}", path, item.name) - }; - self.delete_recursive(&item_path)?; + if location.is_directory { + // Get all contents and delete them first + let contents = self.list_files_in_directory(location.first_cluster, "")?; + for item in contents { + let item_path = if path.ends_with('/') { + format!("{}{}", path, item.name) + } else { + format!("{}/{}", path, item.name) + }; + self.delete_recursive(&item_path)?; + } } - } - // Now delete the item itself - if location.first_cluster >= 2 { - self.free_cluster_chain(location.first_cluster)?; - } - self.delete_file_entry(&location)?; + // Now delete the item itself + if location.first_cluster >= 2 { + self.free_cluster_chain(location.first_cluster)?; + } + self.delete_file_entry(&location)?; - self.file.flush()?; - Ok(()) + self.file.flush()?; + Ok(()) + })(); + self.finish_write_transaction(was_dirty, result) } /// Create a directory at a specific path /// /// If create_parents is true, creates all intermediate directories (mkdir -p behavior) pub fn create_directory(&mut self, path: &str, create_parents: bool) -> Result<()> { - let resolved = self.resolve_path(path, create_parents)?; + let was_dirty = self.begin_write_transaction()?; + let result = (|| { + let resolved = self.resolve_path(path, create_parents)?; - if resolved.location.is_some() { - return Err(VentoyError::FilesystemError(format!( - "'{}' already exists", - path - ))); - } + if resolved.location.is_some() { + return Err(VentoyError::FilesystemError(format!( + "'{}' already exists", + path + ))); + } - self.create_directory_in(resolved.parent_cluster, &resolved.name)?; - Ok(()) + self.create_directory_in(resolved.parent_cluster, &resolved.name)?; + Ok(()) + })(); + self.finish_write_transaction(was_dirty, result) } } @@ -1805,9 +2080,13 @@ impl ExfatFs { reader: &mut R, size: u64, ) -> Result<()> { - let mut writer = ExfatFileWriter::create(self, name, size)?; - Self::do_stream_write(&mut writer, reader)?; - writer.finish() + let was_dirty = self.begin_write_transaction()?; + let result = (|| { + let mut writer = ExfatFileWriter::create(self, name, size)?; + Self::do_stream_write(&mut writer, reader)?; + writer.finish() + })(); + self.finish_write_transaction(was_dirty, result) } /// Write a file from a reader with overwrite option @@ -1818,9 +2097,13 @@ impl ExfatFs { size: u64, overwrite: bool, ) -> Result<()> { - let mut writer = ExfatFileWriter::create_overwrite(self, name, size, overwrite)?; - Self::do_stream_write(&mut writer, reader)?; - writer.finish() + let was_dirty = self.begin_write_transaction()?; + let result = (|| { + let mut writer = ExfatFileWriter::create_overwrite(self, name, size, overwrite)?; + Self::do_stream_write(&mut writer, reader)?; + writer.finish() + })(); + self.finish_write_transaction(was_dirty, result) } /// Write a file from a reader to a specific path @@ -1835,10 +2118,14 @@ impl ExfatFs { create_parents: bool, overwrite: bool, ) -> Result<()> { - let mut writer = - ExfatFileWriter::create_at_path(self, path, size, create_parents, overwrite)?; - Self::do_stream_write(&mut writer, reader)?; - writer.finish() + let was_dirty = self.begin_write_transaction()?; + let result = (|| { + let mut writer = + ExfatFileWriter::create_at_path(self, path, size, create_parents, overwrite)?; + Self::do_stream_write(&mut writer, reader)?; + writer.finish() + })(); + self.finish_write_transaction(was_dirty, result) } /// Internal: Stream write from reader to writer @@ -1861,9 +2148,18 @@ impl ExfatFs { mod tests { use super::*; use crate::partition::PartitionLayout; - use std::io::Cursor; + use std::io::{Cursor, Read, Seek, SeekFrom, Write}; use tempfile::NamedTempFile; + fn cluster_offset( + partition_offset: u64, + cluster_heap_offset: u32, + cluster_size: u64, + cluster: u32, + ) -> u64 { + partition_offset + cluster_heap_offset as u64 * 512 + (cluster - 2) as u64 * cluster_size + } + /// Test directory extension when filling up a directory cluster #[test] fn test_directory_extension() -> Result<()> { @@ -2055,6 +2351,168 @@ mod tests { Ok(()) } + #[test] + fn test_write_transactions_clear_volume_dirty_on_success() -> Result<()> { + let temp_file = NamedTempFile::new().unwrap(); + let path = temp_file.path(); + let size = 64 * 1024 * 1024u64; + let layout = PartitionLayout::calculate(size).unwrap(); + + let mut file = std::fs::OpenOptions::new() + .read(true) + .write(true) + .open(path) + .unwrap(); + file.set_len(size).unwrap(); + crate::exfat::format::format_exfat( + &mut file, + layout.data_offset(), + layout.data_size(), + "TEST", + ) + .unwrap(); + drop(file); + + let mut fs = ExfatFs::open(path, &layout).unwrap(); + assert!(!fs.is_volume_dirty()?); + + let data = b"uploaded from web"; + let mut cursor = Cursor::new(data); + fs.write_file_from_reader_path( + "/uploads/test.txt", + &mut cursor, + data.len() as u64, + true, + true, + )?; + assert!(!fs.is_volume_dirty()?); + + fs.delete_recursive("/uploads")?; + assert!(!fs.is_volume_dirty()?); + + Ok(()) + } + + #[test] + fn test_created_directory_has_allocated_data_length() -> Result<()> { + let temp_file = NamedTempFile::new().unwrap(); + let path = temp_file.path(); + let size = 64 * 1024 * 1024u64; + let layout = PartitionLayout::calculate(size).unwrap(); + + let mut file = std::fs::OpenOptions::new() + .read(true) + .write(true) + .open(path) + .unwrap(); + file.set_len(size).unwrap(); + crate::exfat::format::format_exfat( + &mut file, + layout.data_offset(), + layout.data_size(), + "TEST", + ) + .unwrap(); + drop(file); + + let mut fs = ExfatFs::open(path, &layout).unwrap(); + fs.create_directory("/uploads", true)?; + + let location = fs + .find_entry_in_directory(fs.first_cluster_of_root, "uploads")? + .expect("created directory entry should exist"); + + assert!(location.is_directory); + assert!(location.first_cluster >= 2); + assert_eq!(location.data_length, fs.cluster_size as u64); + + Ok(()) + } + + #[test] + fn test_open_uses_bitmap_location_from_root_directory() -> Result<()> { + let temp_file = NamedTempFile::new().unwrap(); + let path = temp_file.path(); + let size = 64 * 1024 * 1024u64; + let layout = PartitionLayout::calculate(size).unwrap(); + + let mut file = std::fs::OpenOptions::new() + .read(true) + .write(true) + .open(path) + .unwrap(); + file.set_len(size).unwrap(); + crate::exfat::format::format_exfat( + &mut file, + layout.data_offset(), + layout.data_size(), + "TEST", + ) + .unwrap(); + + let mut boot_sector = [0u8; 512]; + file.seek(SeekFrom::Start(layout.data_offset())).unwrap(); + file.read_exact(&mut boot_sector).unwrap(); + let fat_offset = u32::from_le_bytes(boot_sector[80..84].try_into().unwrap()); + let cluster_heap_offset = u32::from_le_bytes(boot_sector[88..92].try_into().unwrap()); + let first_cluster_of_root = u32::from_le_bytes(boot_sector[96..100].try_into().unwrap()); + let cluster_size = (1u64 << boot_sector[109]) * 512; + + let relocated_bitmap_cluster = first_cluster_of_root + 1; + let mut bitmap = vec![0u8; cluster_size as usize]; + file.seek(SeekFrom::Start(cluster_offset( + layout.data_offset(), + cluster_heap_offset, + cluster_size, + 2, + ))) + .unwrap(); + file.read_exact(&mut bitmap).unwrap(); + let relocated_index = (relocated_bitmap_cluster - 2) as usize; + bitmap[relocated_index / 8] |= 1 << (relocated_index % 8); + + file.seek(SeekFrom::Start(cluster_offset( + layout.data_offset(), + cluster_heap_offset, + cluster_size, + relocated_bitmap_cluster, + ))) + .unwrap(); + file.write_all(&bitmap).unwrap(); + + let relocated_fat_offset = + layout.data_offset() + fat_offset as u64 * 512 + relocated_bitmap_cluster as u64 * 4; + file.seek(SeekFrom::Start(relocated_fat_offset)).unwrap(); + file.write_all(&FAT_ENTRY_END_OF_CHAIN.to_le_bytes()) + .unwrap(); + + let root_offset = cluster_offset( + layout.data_offset(), + cluster_heap_offset, + cluster_size, + first_cluster_of_root, + ); + file.seek(SeekFrom::Start(root_offset + 32 + 20)).unwrap(); + file.write_all(&relocated_bitmap_cluster.to_le_bytes()) + .unwrap(); + file.flush().unwrap(); + drop(file); + + let mut fs = ExfatFs::open(path, &layout).unwrap(); + assert_eq!(fs.allocation_bitmap_first_cluster, relocated_bitmap_cluster); + assert!(ExfatFs::is_cluster_allocated( + &fs.read_bitmap()?, + relocated_bitmap_cluster + )); + + let data = b"uses relocated bitmap"; + let mut cursor = Cursor::new(data); + fs.write_file_from_reader("relocated.txt", &mut cursor, data.len() as u64)?; + assert_eq!(fs.read_file("relocated.txt")?, &data[..]); + + Ok(()) + } + /// Test Unicode file names (CJK, Cyrillic, emoji, etc.) #[test] fn test_unicode_filenames() -> Result<()> { diff --git a/scripts/generate-types.sh b/scripts/generate-types.sh index 21171010..7305edbd 100755 --- a/scripts/generate-types.sh +++ b/scripts/generate-types.sh @@ -20,6 +20,9 @@ typeshare "$PROJECT_ROOT/src" \ --lang=typescript \ --output-file="$OUTPUT_FILE" +# Keep generated output stable for git diff --check. +perl -0pi -e 's/\n+\z/\n/' "$OUTPUT_FILE" + echo "" echo "TypeScript types generated successfully!" echo "Output: $OUTPUT_FILE" 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/hidraw_linux.rs b/src/atx/hidraw_linux.rs index b95f357a..3b53c8cc 100644 --- a/src/atx/hidraw_linux.rs +++ b/src/atx/hidraw_linux.rs @@ -1,7 +1,6 @@ use async_trait::async_trait; use std::fs::{File, OpenOptions}; use std::io::Write; -use std::os::fd::AsRawFd; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Mutex; use std::time::Duration; @@ -9,12 +8,10 @@ use tokio::time::sleep; use tracing::{debug, info}; use super::traits::AtxKeyBackend; -use super::types::AtxKeyConfig; +use super::types::{AtxKeyConfig, LCUS_RELAY_MAX_CHANNEL}; use crate::error::{AppError, Result}; -const USB_RELAY_MAX_CHANNEL: u8 = 8; -const USB_RELAY_REPORT_LEN: usize = 9; -const HIDIOCSFEATURE_9: libc::c_ulong = 0xC009_4806; +const LCUS_RELAY_COMMAND_LEN: usize = 4; pub struct HidrawLinuxRelayBackend { config: AtxKeyConfig, @@ -34,13 +31,13 @@ impl HidrawLinuxRelayBackend { fn validate_config(&self) -> Result<()> { if self.config.pin == 0 { return Err(AppError::Config( - "USB relay channel must be 1-based (>= 1)".to_string(), + "LCUS HID relay channel must be 1-based (>= 1)".to_string(), )); } - if self.config.pin > USB_RELAY_MAX_CHANNEL as u32 { + if self.config.pin > LCUS_RELAY_MAX_CHANNEL as u32 { return Err(AppError::Config(format!( - "USB HID relay channel must be <= {}", - USB_RELAY_MAX_CHANNEL + "LCUS HID relay channel must be <= {}", + LCUS_RELAY_MAX_CHANNEL ))); } Ok(()) @@ -49,20 +46,19 @@ impl HidrawLinuxRelayBackend { fn send_command(&self, on: bool) -> Result<()> { let channel = u8::try_from(self.config.pin).map_err(|_| { AppError::Config(format!( - "USB relay channel {} exceeds max {}", - self.config.pin, - u8::MAX + "LCUS HID relay channel {} exceeds max {}", + self.config.pin, LCUS_RELAY_MAX_CHANNEL )) })?; if channel == 0 { return Err(AppError::Config( - "USB relay channel must be 1-based (>= 1)".to_string(), + "LCUS HID relay channel must be 1-based (>= 1)".to_string(), )); } - if channel > USB_RELAY_MAX_CHANNEL { + if channel > LCUS_RELAY_MAX_CHANNEL { return Err(AppError::Config(format!( - "USB HID relay channel must be <= {}", - USB_RELAY_MAX_CHANNEL + "LCUS HID relay channel must be <= {}", + LCUS_RELAY_MAX_CHANNEL ))); } @@ -70,44 +66,22 @@ impl HidrawLinuxRelayBackend { let mut guard = self.handle.lock().unwrap(); let device = guard .as_mut() - .ok_or_else(|| AppError::Internal("USB relay not initialized".to_string()))?; + .ok_or_else(|| AppError::Internal("LCUS HID relay not initialized".to_string()))?; - if let Err(feature_err) = Self::send_feature_report(device, &cmd) { - debug!( - "USB relay feature report failed ({}), falling back to hidraw write", - feature_err - ); - device.write_all(&cmd).map_err(|write_err| { - AppError::Internal(format!( - "USB relay feature report failed: {}; raw write failed: {}", - feature_err, write_err - )) - })?; - device - .flush() - .map_err(|e| AppError::Internal(format!("USB relay flush failed: {}", e)))?; - } + device + .write_all(&cmd) + .map_err(|e| AppError::Internal(format!("LCUS HID relay write failed: {}", e)))?; + device + .flush() + .map_err(|e| AppError::Internal(format!("LCUS HID relay flush failed: {}", e)))?; Ok(()) } - pub fn build_command(channel: u8, on: bool) -> [u8; USB_RELAY_REPORT_LEN] { - let mut cmd = [0x00; USB_RELAY_REPORT_LEN]; - cmd[1] = if on { 0xFF } else { 0xFD }; - cmd[2] = channel; - cmd - } - - fn send_feature_report( - device: &File, - report: &[u8; USB_RELAY_REPORT_LEN], - ) -> std::io::Result<()> { - let rc = unsafe { libc::ioctl(device.as_raw_fd(), HIDIOCSFEATURE_9 as _, report.as_ptr()) }; - if rc < 0 { - Err(std::io::Error::last_os_error()) - } else { - Ok(()) - } + pub fn build_command(channel: u8, on: bool) -> [u8; LCUS_RELAY_COMMAND_LEN] { + let state = if on { 1 } else { 0 }; + let checksum = 0xA0u8.wrapping_add(channel).wrapping_add(state); + [0xA0, channel, state, checksum] } } @@ -117,7 +91,7 @@ impl AtxKeyBackend for HidrawLinuxRelayBackend { self.validate_config()?; info!( - "Initializing USB relay ATX backend on {} channel {}", + "Initializing LCUS HID relay ATX backend on {} channel {}", self.config.device, self.config.pin ); @@ -125,14 +99,14 @@ impl AtxKeyBackend for HidrawLinuxRelayBackend { .read(true) .write(true) .open(&self.config.device) - .map_err(|e| AppError::Internal(format!("USB relay device open failed: {}", e)))?; + .map_err(|e| AppError::Internal(format!("LCUS HID relay device open failed: {}", e)))?; *self.handle.lock().unwrap() = Some(device); self.send_command(false)?; self.initialized.store(true, Ordering::Relaxed); debug!( - "USB relay channel {} configured successfully", + "LCUS HID relay channel {} configured successfully", self.config.pin ); Ok(()) @@ -140,7 +114,9 @@ impl AtxKeyBackend for HidrawLinuxRelayBackend { async fn pulse(&self, duration: Duration) -> Result<()> { if !self.is_initialized() { - return Err(AppError::Internal("USB relay not initialized".to_string())); + return Err(AppError::Internal( + "LCUS HID relay not initialized".to_string(), + )); } self.send_command(true)?; @@ -177,14 +153,14 @@ mod tests { use super::HidrawLinuxRelayBackend; #[test] - fn usb_relay_command_format() { + fn lcus_hid_relay_command_format() { assert_eq!( HidrawLinuxRelayBackend::build_command(1, true), - [0x00, 0xFF, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00] + [0xA0, 0x01, 0x01, 0xA2] ); assert_eq!( HidrawLinuxRelayBackend::build_command(1, false), - [0x00, 0xFD, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00] + [0xA0, 0x01, 0x00, 0xA1] ); } } 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..3b51f8c4 100644 --- a/src/atx/mod.rs +++ b/src/atx/mod.rs @@ -24,22 +24,17 @@ 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, LCUS_RELAY_MAX_CHANNEL, }; pub use wol::{list_wol_history, record_wol_history, send_wol}; #[cfg(any(unix, test))] fn hidraw_uevent_is_usb_relay(uevent: &str) -> bool { let upper = uevent.to_ascii_uppercase(); - upper.contains("000016C0:000005DF") - || upper.contains("00005131:00002007") - || upper.contains("16C0:05DF") + upper.contains("00005131:00002007") || upper.contains("5131:2007") - || upper.contains("PRODUCT=16C0/5DF") || upper.contains("PRODUCT=5131/2007") - || upper.contains("USBRELAY") - || upper.contains("USB RELAY") } #[cfg(unix)] @@ -94,14 +89,14 @@ mod tests { } #[test] - fn test_hidraw_uevent_detects_usb_relay_id() { - assert!(hidraw_uevent_is_usb_relay( + fn test_hidraw_uevent_rejects_non_lcus_usb_relay_id() { + assert!(!hidraw_uevent_is_usb_relay( "HID_ID=0003:000016C0:000005DF\nHID_NAME=www.dcttech.com USBRelay2\n" )); } #[test] - fn test_hidraw_uevent_detects_5131_usb_relay_id() { + fn test_hidraw_uevent_detects_lcus_hid_relay_id() { assert!(hidraw_uevent_is_usb_relay( "HID_ID=0003:00005131:00002007\n" )); @@ -120,7 +115,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/serial_relay.rs b/src/atx/serial_relay.rs index bdc23f7b..a21f5002 100644 --- a/src/atx/serial_relay.rs +++ b/src/atx/serial_relay.rs @@ -7,7 +7,7 @@ use tokio::time::sleep; use tracing::{debug, info}; use super::traits::{validate_serial_config, AtxKeyBackend, SharedSerialHandle}; -use super::types::AtxKeyConfig; +use super::types::{AtxKeyConfig, LCUS_RELAY_MAX_CHANNEL}; use crate::error::{AppError, Result}; pub struct SerialRelayBackend { @@ -44,15 +44,23 @@ impl SerialRelayBackend { fn send_command(&self, on: bool) -> Result<()> { let channel = u8::try_from(self.config.pin).map_err(|_| { AppError::Config(format!( - "Serial relay channel {} exceeds max {}", - self.config.pin, - u8::MAX + "LCUS serial relay channel {} exceeds max {}", + self.config.pin, LCUS_RELAY_MAX_CHANNEL )) })?; + if channel == 0 { + return Err(AppError::Config( + "LCUS serial relay channel must be 1-based (>= 1)".to_string(), + )); + } + if channel > LCUS_RELAY_MAX_CHANNEL { + return Err(AppError::Config(format!( + "LCUS serial relay channel must be <= {}", + LCUS_RELAY_MAX_CHANNEL + ))); + } - let state = if on { 1 } else { 0 }; - let checksum = 0xA0u8.wrapping_add(channel).wrapping_add(state); - let cmd = [0xA0, channel, state, checksum]; + let cmd = Self::build_command(channel, on); let serial_handle = self .serial_handle @@ -60,16 +68,22 @@ impl SerialRelayBackend { .unwrap() .as_ref() .cloned() - .ok_or_else(|| AppError::Internal("Serial relay not initialized".to_string()))?; + .ok_or_else(|| AppError::Internal("LCUS serial relay not initialized".to_string()))?; let mut port = serial_handle.lock().unwrap(); port.write_all(&cmd) - .map_err(|e| AppError::Internal(format!("Serial relay write failed: {}", e)))?; + .map_err(|e| AppError::Internal(format!("LCUS serial relay write failed: {}", e)))?; port.flush() - .map_err(|e| AppError::Internal(format!("Serial relay flush failed: {}", e)))?; + .map_err(|e| AppError::Internal(format!("LCUS serial relay flush failed: {}", e)))?; Ok(()) } + + pub fn build_command(channel: u8, on: bool) -> [u8; 4] { + let state = if on { 1 } else { 0 }; + let checksum = 0xA0u8.wrapping_add(channel).wrapping_add(state); + [0xA0, channel, state, checksum] + } } #[async_trait] @@ -78,7 +92,7 @@ impl AtxKeyBackend for SerialRelayBackend { validate_serial_config(&self.config)?; info!( - "Initializing Serial relay ATX backend on {} channel {}", + "Initializing LCUS serial relay ATX backend on {} channel {}", self.config.device, self.config.pin ); @@ -92,7 +106,7 @@ impl AtxKeyBackend for SerialRelayBackend { self.initialized.store(true, Ordering::Relaxed); debug!( - "Serial relay channel {} configured successfully", + "LCUS serial relay channel {} configured successfully", self.config.pin ); Ok(()) @@ -101,12 +115,12 @@ impl AtxKeyBackend for SerialRelayBackend { async fn pulse(&self, duration: Duration) -> Result<()> { if !self.is_initialized() { return Err(AppError::Internal( - "Serial relay not initialized".to_string(), + "LCUS serial relay not initialized".to_string(), )); } info!( - "Pulse serial relay on {} pin {}", + "Pulse LCUS serial relay on {} channel {}", self.config.device, self.config.pin ); self.send_command(true)?; @@ -131,6 +145,23 @@ impl AtxKeyBackend for SerialRelayBackend { } } +#[cfg(test)] +mod tests { + use super::SerialRelayBackend; + + #[test] + fn lcus_serial_relay_command_format() { + assert_eq!( + SerialRelayBackend::build_command(1, true), + [0xA0, 0x01, 0x01, 0xA2] + ); + assert_eq!( + SerialRelayBackend::build_command(1, false), + [0xA0, 0x01, 0x00, 0xA1] + ); + } +} + impl Drop for SerialRelayBackend { fn drop(&mut self) { if self.is_initialized() { diff --git a/src/atx/traits.rs b/src/atx/traits.rs index d422b2a9..13e4bedd 100644 --- a/src/atx/traits.rs +++ b/src/atx/traits.rs @@ -3,7 +3,7 @@ use serialport::SerialPort; use std::sync::{Arc, Mutex}; use std::time::Duration; -use super::types::AtxKeyConfig; +use super::types::{AtxKeyConfig, LCUS_RELAY_MAX_CHANNEL}; use crate::error::Result; pub type SharedSerialHandle = Arc>>; @@ -36,10 +36,10 @@ pub fn validate_serial_config(config: &AtxKeyConfig) -> Result<()> { "Serial ATX channel must be 1-based (>= 1)".to_string(), )); } - if config.pin > u8::MAX as u32 { + if config.pin > LCUS_RELAY_MAX_CHANNEL as u32 { return Err(crate::error::AppError::Config(format!( - "Serial ATX channel must be <= {}", - u8::MAX + "LCUS serial relay channel must be <= {}", + LCUS_RELAY_MAX_CHANNEL ))); } if config.baud_rate == 0 { diff --git a/src/atx/types.rs b/src/atx/types.rs index ba353062..306401bd 100644 --- a/src/atx/types.rs +++ b/src/atx/types.rs @@ -1,11 +1,12 @@ //! 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; +pub const LCUS_RELAY_MAX_CHANNEL: u8 = 8; + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[serde(rename_all = "lowercase")] pub enum PowerStatus { @@ -15,6 +16,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 +46,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 +124,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 +186,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 +219,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 +242,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/computer_use/actions.rs b/src/computer_use/actions.rs index 3e28d08c..dd879d37 100644 --- a/src/computer_use/actions.rs +++ b/src/computer_use/actions.rs @@ -29,7 +29,8 @@ impl Default for ComputerUseButton { } } -#[typeshare] +// Kept out of typeshare because these internally tagged enums are consumed by +// manually maintained frontend types and changing serde shape would break WS/API. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] pub enum ComputerUseAction { @@ -89,7 +90,6 @@ pub struct ComputerUseScreenshot { pub height: u32, } -#[typeshare] #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "role", rename_all = "snake_case")] pub enum ComputerUseConversationMessage { @@ -145,7 +145,6 @@ pub struct ComputerUseSessionSummary { pub final_message: Option, } -#[typeshare] #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] pub enum ComputerUseWsClientMessage { @@ -155,7 +154,6 @@ pub enum ComputerUseWsClientMessage { }, } -#[typeshare] #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] pub enum ComputerUseWsServerMessage { @@ -166,3 +164,43 @@ pub enum ComputerUseWsServerMessage { ActionsExecuted { actions: Vec }, Error { message: String }, } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn computer_use_action_keeps_flat_tagged_json_shape() { + let action = ComputerUseAction::Click { + x: 10, + y: 20, + button: ComputerUseButton::Left, + }; + + assert_eq!( + serde_json::to_value(action).unwrap(), + json!({ + "type": "click", + "x": 10, + "y": 20, + "button": "left" + }) + ); + } + + #[test] + fn computer_use_ws_message_keeps_flat_tagged_json_shape() { + let message = ComputerUseWsServerMessage::ScreenshotRequested { + request_id: "req-1".to_string(), + }; + + assert_eq!( + serde_json::to_value(message).unwrap(), + json!({ + "type": "screenshot_requested", + "request_id": "req-1" + }) + ); + } +} 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/msd/mod.rs b/src/msd/mod.rs index fc162890..a5656b4b 100644 --- a/src/msd/mod.rs +++ b/src/msd/mod.rs @@ -11,6 +11,6 @@ pub use types::{ DownloadProgress, DownloadStatus, DriveFile, DriveInfo, DriveInitRequest, ImageDownloadRequest, ImageInfo, MsdConnectRequest, MsdMode, MsdState, }; -pub use ventoy_drive::VentoyDrive; +pub use ventoy_drive::{VentoyDrive, MIN_DRIVE_SIZE_MB}; pub use crate::otg::{MsdFunction, MsdLunConfig}; diff --git a/src/msd/types.rs b/src/msd/types.rs index 903ce326..296fab35 100644 --- a/src/msd/types.rs +++ b/src/msd/types.rs @@ -120,7 +120,7 @@ pub struct DriveInitRequest { } fn default_drive_size() -> u32 { - 16 * 1024 + 64 } #[derive(Debug, Clone, Deserialize)] diff --git a/src/msd/ventoy_drive.rs b/src/msd/ventoy_drive.rs index e3d3dc44..8c9748c7 100644 --- a/src/msd/ventoy_drive.rs +++ b/src/msd/ventoy_drive.rs @@ -10,9 +10,7 @@ use crate::error::{AppError, Result}; const STREAM_CHUNK_SIZE: usize = 64 * 1024; -const MIN_DRIVE_SIZE_MB: u32 = 1024; - -const MAX_DRIVE_SIZE_MB: u32 = 128 * 1024; +pub const MIN_DRIVE_SIZE_MB: u32 = 64; const DEFAULT_LABEL: &str = "ONE-KVM"; @@ -37,8 +35,20 @@ impl VentoyDrive { &self.path } + /// Returns just the raw file size without attempting to parse the filesystem. + /// Used as a fallback when the image has been reformatted to an unsupported + /// filesystem (e.g. NTFS/exFAT) that VentoyImage cannot open. + pub fn raw_size(&self) -> Option { + std::fs::metadata(&self.path).ok().map(|m| m.len()) + } + pub async fn init(&self, size_mb: u32) -> Result { - let size_mb = size_mb.clamp(MIN_DRIVE_SIZE_MB, MAX_DRIVE_SIZE_MB); + if size_mb < MIN_DRIVE_SIZE_MB { + return Err(AppError::BadRequest(format!( + "Drive size must be at least {} MB", + MIN_DRIVE_SIZE_MB + ))); + } let size_str = format!("{}M", size_mb); let path = self.path.clone(); let _lock = self.lock.write().await; diff --git a/src/otg/msd.rs b/src/otg/msd.rs index a762d74b..89be3617 100644 --- a/src/otg/msd.rs +++ b/src/otg/msd.rs @@ -44,7 +44,11 @@ impl MsdLunConfig { cdrom: false, ro: read_only, removable: true, - nofua: true, + // nofua=false: enforce Force Unit Access so the USB host (e.g. Windows) + // gets proper write-completion acknowledgements when writing to the + // virtual .img file. nofua=true can cause write-verify failures + // that manifest as Windows error 0x80070570 on writable drives. + nofua: false, } } } 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/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/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/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/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/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); 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/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/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 a6193650..08330d71 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,143 @@ 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 LCUS HID relay")?; + Self::validate_output_channel( + &config.power, + "power", + 1, + crate::atx::LCUS_RELAY_MAX_CHANNEL as u32, + )?; + Self::validate_output_channel( + &config.reset, + "reset", + 1, + crate::atx::LCUS_RELAY_MAX_CHANNEL as u32, + )?; } - crate::atx::AtxDriverType::Gpio | crate::atx::AtxDriverType::None => {} + crate::atx::AtxDriverType::Serial => { + Self::validate_device_path(&config.device, "ATX LCUS serial relay")?; + 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, + crate::atx::LCUS_RELAY_MAX_CHANNEL as u32, + )?; + Self::validate_output_channel( + &config.reset, + "reset", + 1, + crate::atx::LCUS_RELAY_MAX_CHANNEL 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 +672,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 +1258,111 @@ 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_lcus_serial_channel_over_8() { let mut current = AtxConfig::default(); - current.power.driver = crate::atx::AtxDriverType::Serial; - current.power.device = "/dev/ttyUSB0".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.reset.driver = crate::atx::AtxDriverType::Serial; - current.reset.device = "/dev/ttyUSB0".to_string(); - current.reset.pin = 2; - current.reset.baud_rate = 9600; + current.baud_rate = 9600; - let update = AtxConfigUpdate { + 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()); + } + + #[test] + fn test_atx_validate_with_current_rejects_enabled_none_driver() { + let mut current = AtxConfig::default(); + current.driver = crate::atx::AtxDriverType::None; + + 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, + device: None, + pin: Some(9), + active_level: None, + }); assert!(update.validate_with_current(¤t).is_err()); } @@ -1335,4 +1394,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}" + ); + } + } } diff --git a/src/web/handlers/msd_api.rs b/src/web/handlers/msd_api.rs index 430a0b23..9eaebe01 100644 --- a/src/web/handlers/msd_api.rs +++ b/src/web/handlers/msd_api.rs @@ -2,7 +2,7 @@ use super::*; use crate::msd::{ DownloadProgress, DriveFile, DriveInfo, DriveInitRequest, ImageDownloadRequest, ImageInfo, - ImageManager, MsdConnectRequest, MsdMode, MsdState, VentoyDrive, + ImageManager, MsdConnectRequest, MsdMode, MsdState, VentoyDrive, MIN_DRIVE_SIZE_MB, }; #[cfg(unix)] use axum::body::Body; @@ -15,6 +15,63 @@ use axum::response::Response; #[cfg(unix)] use std::collections::HashMap; +#[cfg(unix)] +const MIB: u64 = 1024 * 1024; + +/// Return an error if the virtual drive is currently connected to the USB host. +/// When connected, the USB host (e.g. Windows) has the filesystem mounted. +/// Any concurrent access from the server side (via VentoyImage::open) would +/// cause double-access corruption, manifesting as Windows error 0x80070570. +#[cfg(unix)] +async fn assert_drive_not_connected(state: &Arc) -> Result<()> { + let msd_guard = state.msd.read().await; + if let Some(controller) = msd_guard.as_ref() { + let msd_state = controller.state().await; + if msd_state.connected && msd_state.mode == crate::msd::types::MsdMode::Drive { + return Err(AppError::BadRequest( + "Virtual drive is connected to the USB host; disconnect it before modifying files" + .to_string(), + )); + } + } + Ok(()) +} + +#[cfg(unix)] +fn validate_drive_init_size(size_mb: u32, available_bytes: u64) -> Result<()> { + let requested_bytes = size_mb as u64 * MIB; + if size_mb < MIN_DRIVE_SIZE_MB { + return Err(AppError::BadRequest(format!( + "Virtual drive size must be at least {} MB", + MIN_DRIVE_SIZE_MB + ))); + } + if requested_bytes > available_bytes { + return Err(AppError::BadRequest(format!( + "Virtual drive size cannot exceed available space on the MSD directory filesystem (available {} MB, requested {} MB)", + available_bytes / MIB, + size_mb + ))); + } + Ok(()) +} + +#[cfg(unix)] +fn is_unsupported_drive_filesystem(error: &str) -> bool { + error.contains("Filesystem error") + || error.contains("Image error") + || error.contains("Partition error") +} + +#[cfg(unix)] +fn unsupported_drive_filesystem_error(error: &str) -> AppError { + tracing::warn!( + error = %error, + "Virtual drive filesystem is not supported" + ); + AppError::BadRequest("Unsupported drive filesystem".to_string()) +} + /// MSD status response #[cfg(unix)] #[derive(Serialize)] @@ -226,11 +283,24 @@ pub async fn msd_drive_info(State(state): State>) -> Result Ok(Json(info)), + Err(e) => { + let msg = e.to_string(); + // Detect filesystem-level failures (unrecognized format, bad partition table, etc.) + // These mean the drive FILE exists but was formatted to an unsupported type + // (e.g. the controlled machine reformatted it as NTFS/exFAT). + // Return 400 so the frontend can distinguish this from 404 (file missing). + if is_unsupported_drive_filesystem(&msg) { + return Err(unsupported_drive_filesystem_error(&msg)); + } + Err(e) + } + } } /// Initialize Ventoy drive @@ -240,6 +310,16 @@ pub async fn msd_drive_init( Json(req): Json, ) -> Result> { let config = state.config.get(); + let msd_dir = config.msd.msd_dir_path(); + + let disk_space = get_disk_space(&msd_dir).map_err(|e| { + AppError::BadRequest(format!( + "Failed to read available space for the MSD directory filesystem: {}", + e + )) + })?; + validate_drive_init_size(req.size_mb, disk_space.available)?; + let drive_path = config.msd.drive_path(); let drive = VentoyDrive::new(drive_path); @@ -283,12 +363,24 @@ pub async fn msd_drive_files( State(state): State>, Query(params): Query>, ) -> Result>> { + // Block when connected: concurrent access corrupts the filesystem + assert_drive_not_connected(&state).await?; + let config = state.config.get(); let drive_path = config.msd.drive_path(); let drive = VentoyDrive::new(drive_path); let dir_path = params.get("path").map(|s| s.as_str()).unwrap_or("/"); - let files = drive.list_files(dir_path).await?; + let files = drive.list_files(dir_path).await.map_err(|e| { + // Provide a friendly message when the filesystem format is unrecognized + // (e.g. user formatted it as NTFS/exFAT from the controlled machine) + let msg = e.to_string(); + if is_unsupported_drive_filesystem(&msg) { + unsupported_drive_filesystem_error(&msg) + } else { + e + } + })?; Ok(Json(files)) } @@ -299,6 +391,10 @@ pub async fn msd_drive_upload( Query(params): Query>, mut multipart: Multipart, ) -> Result> { + // Block when connected: writing to image while USB host has it mounted + // causes filesystem corruption (Windows error 0x80070570) + assert_drive_not_connected(&state).await?; + let config = state.config.get(); let drive_path = config.msd.drive_path(); let drive = VentoyDrive::new(drive_path); @@ -345,6 +441,10 @@ pub async fn msd_drive_download( State(state): State>, AxumPath(file_path): AxumPath, ) -> Result { + // Block when connected: concurrent read from server side can cause + // filesystem inconsistency while USB host has the image mounted + assert_drive_not_connected(&state).await?; + let config = state.config.get(); let drive_path = config.msd.drive_path(); let drive = VentoyDrive::new(drive_path); @@ -380,6 +480,10 @@ pub async fn msd_drive_file_delete( State(state): State>, AxumPath(file_path): AxumPath, ) -> Result> { + // Block when connected: deleting from image while USB host has it mounted + // causes filesystem corruption + assert_drive_not_connected(&state).await?; + let config = state.config.get(); let drive_path = config.msd.drive_path(); let drive = VentoyDrive::new(drive_path); @@ -398,6 +502,10 @@ pub async fn msd_drive_mkdir( State(state): State>, AxumPath(dir_path): AxumPath, ) -> Result> { + // Block when connected: modifying image while USB host has it mounted + // causes filesystem corruption + assert_drive_not_connected(&state).await?; + let config = state.config.get(); let drive_path = config.msd.drive_path(); let drive = VentoyDrive::new(drive_path); @@ -409,3 +517,38 @@ pub async fn msd_drive_mkdir( message: Some(format!("Directory created: {}", dir_path)), })) } + +#[cfg(all(test, unix))] +mod tests { + use super::*; + + #[test] + fn validate_drive_init_size_accepts_64mb() { + validate_drive_init_size(MIN_DRIVE_SIZE_MB, MIN_DRIVE_SIZE_MB as u64 * MIB).unwrap(); + } + + #[test] + fn validate_drive_init_size_rejects_below_64mb() { + let err = validate_drive_init_size(MIN_DRIVE_SIZE_MB - 1, 1024 * MIB).unwrap_err(); + assert!(err.to_string().contains("at least 64 MB")); + } + + #[test] + fn validate_drive_init_size_rejects_available_space_overflow() { + let err = validate_drive_init_size(65, 64 * MIB).unwrap_err(); + assert!(err.to_string().contains("cannot exceed available space")); + } + + #[test] + fn detects_unsupported_drive_filesystem_errors() { + assert!(is_unsupported_drive_filesystem( + "Internal error: Filesystem error: Invalid exFAT signature" + )); + assert!(is_unsupported_drive_filesystem( + "Internal error: Partition error: invalid partition table" + )); + assert!(!is_unsupported_drive_filesystem( + "IO error: permission denied" + )); + } +} diff --git a/src/web/handlers/system.rs b/src/web/handlers/system.rs index 15ded06f..36f22c78 100644 --- a/src/web/handlers/system.rs +++ b/src/web/handlers/system.rs @@ -18,7 +18,6 @@ pub async fn health_check() -> Json { #[derive(Serialize)] pub struct SystemInfo { pub version: &'static str, - pub build_date: &'static str, pub initialized: bool, pub platform: PlatformCapabilities, pub capabilities: Capabilities, @@ -66,7 +65,6 @@ pub async fn system_info(State(state): State>) -> Json Json(SystemInfo { version: env!("CARGO_PKG_VERSION"), - build_date: env!("BUILD_DATE"), initialized: config.initialized, platform: platform.clone(), capabilities: Capabilities { @@ -88,10 +86,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/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, 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 diff --git a/vcpkg.json b/vcpkg.json index bdfedaa8..76fe2c24 100644 --- a/vcpkg.json +++ b/vcpkg.json @@ -1,7 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/microsoft/vcpkg-tool/main/docs/vcpkg.schema.json", "name": "one-kvm", - "version-string": "0.2.3", + "version-string": "0.2.4", "dependencies": [ { "name": "ffmpeg", diff --git a/web/package-lock.json b/web/package-lock.json index 41790e03..c4e99448 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "web", - "version": "0.2.3", + "version": "0.2.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "web", - "version": "0.2.3", + "version": "0.2.4", "dependencies": { "@vueuse/core": "^14.1.0", "class-variance-authority": "^0.7.1", diff --git a/web/package.json b/web/package.json index 359a21e5..9370b882 100644 --- a/web/package.json +++ b/web/package.json @@ -1,7 +1,7 @@ { "name": "web", "private": true, - "version": "0.2.3", + "version": "0.2.4", "type": "module", "scripts": { "dev": "vite", diff --git a/web/src/api/index.ts b/web/src/api/index.ts index a2b54e2c..6297e7cc 100644 --- a/web/src/api/index.ts +++ b/web/src/api/index.ts @@ -1,5 +1,14 @@ import { request, ApiError } from './request' -import type { CanonicalKey, Ch9329DescriptorState } from '@/types/generated' +import type { + CanonicalKey, + Ch9329DescriptorState, + ComputerUseButton, + ComputerUseConfigResponse, + ComputerUseConfigUpdate, + ComputerUseSessionStatus, + ComputerUseSessionSummary, + ComputerUseStartRequest, +} from '@/types/generated' import { useHidWebSocket, type HidKeyboardEvent, type HidMouseEvent } from '@/composables/useHidWebSocket' const API_BASE = '/api' @@ -77,7 +86,6 @@ export const systemApi = { info: () => request<{ version: string - build_date: string initialized: boolean platform: PlatformCapabilities capabilities: { @@ -457,16 +465,7 @@ export const hidApi = { isWebSocketConnected: () => hidWs.connected.value, } -export type ComputerUseStatus = - | 'idle' - | 'waiting_screenshot' - | 'thinking' - | 'executing' - | 'completed' - | 'failed' - | 'stopped' - -export type ComputerUseButton = 'left' | 'middle' | 'right' +export type ComputerUseStatus = ComputerUseSessionStatus export type ComputerUseAction = | { type: 'click'; x: number; y: number; button?: ComputerUseButton } @@ -479,49 +478,18 @@ export type ComputerUseAction = | { type: 'wait'; ms: number } | { type: 'screenshot' } -export interface ComputerUseScreenshot { - data_url: string - width: number - height: number -} - export type ComputerUseConversationMessage = | { role: 'user'; text: string } | { role: 'assistant'; text: string } -export interface ComputerUseConfig { - enabled: boolean - provider: string - base_url: string - model: string - max_steps: number - timeout_seconds: number - api_key_configured: boolean - api_key_source: string -} +export type ComputerUseConfig = ComputerUseConfigResponse -export interface ComputerUseSession { - id: string | null - status: ComputerUseStatus - prompt: string | null - step: number - max_steps: number - last_error: string | null - final_message: string | null -} +export type ComputerUseSession = ComputerUseSessionSummary export const computerUseApi = { config: () => request('/config/computer-use'), - updateConfig: (data: { - enabled?: boolean - base_url?: string - model?: string - max_steps?: number - timeout_seconds?: number - openai_api_key?: string - clear_openai_api_key?: boolean - }) => + updateConfig: (data: ComputerUseConfigUpdate) => request('/config/computer-use', { method: 'PATCH', body: JSON.stringify(data), @@ -529,7 +497,7 @@ export const computerUseApi = { session: () => request('/computer-use/session'), - start: (data: { prompt: string; continue_conversation?: boolean; client_id: string; max_steps?: number; timeout_seconds?: number }) => + start: (data: ComputerUseStartRequest) => request('/computer-use/session', { method: 'POST', body: JSON.stringify(data), @@ -549,6 +517,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') => @@ -642,19 +612,32 @@ export const msdApi = { used: number free: number initialized: boolean - }>('/msd/drive'), + }>('/msd/drive', {}, { toastOnError: false }), initDrive: (sizeMb?: number) => - request<{ path: string; size_mb: number }>('/msd/drive/init', { - method: 'POST', - body: JSON.stringify({ size_mb: sizeMb }), - }), + request<{ + size: number + used: number + free: number + initialized: boolean + }>( + '/msd/drive/init', + { + method: 'POST', + body: JSON.stringify({ size_mb: sizeMb }), + }, + { toastOnError: false }, + ), deleteDrive: () => request<{ success: boolean }>('/msd/drive', { method: 'DELETE' }), listDriveFiles: (path = '/') => - request(`/msd/drive/files?path=${encodeURIComponent(path)}`), + request( + `/msd/drive/files?path=${encodeURIComponent(path)}`, + {}, + { toastOnError: false }, + ), uploadDriveFile: async (file: File, targetPath = '/', onProgress?: (progress: number) => void) => { const formData = new FormData() @@ -845,6 +828,13 @@ export type { StreamMode, EncoderType, BitratePreset, + ComputerUseButton, + ComputerUseConfigResponse, + ComputerUseConfigUpdate, + ComputerUseScreenshot, + ComputerUseSessionStatus, + ComputerUseSessionSummary, + ComputerUseStartRequest, } from '@/types/generated' export const audioApi = { diff --git a/web/src/components/ActionBar.vue b/web/src/components/ActionBar.vue index 68b83a76..1e0e82a1 100644 --- a/web/src/components/ActionBar.vue +++ b/web/src/components/ActionBar.vue @@ -66,6 +66,7 @@ const props = defineProps<{ showTerminal?: boolean showComputerUse?: boolean }>() +const showStats = computed(() => (props.videoMode ?? 'mjpeg') !== 'mjpeg') const emit = defineEmits<{ (e: 'toggleFullscreen'): void @@ -196,6 +197,7 @@ const RIGHT_FIXED_PX = 120 const collapsibleItems = computed(() => { const items = ITEM_SPECS.slice(3).filter(item => { if (item.id === 'msd' && !showMsd.value) return false + if (item.id === 'stats' && !showStats.value) return false if (item.id === 'terminal' && props.showTerminal === false) return false return true }) @@ -244,6 +246,12 @@ const isVisible = (id: CollapsibleItem) => visibleSet.value.has(id) const hasOverflow = computed(() => { return collapsibleItems.value.some(i => !visibleSet.value.has(i.id)) }) +const hasLeftOverflow = computed(() => { + return collapsibleItems.value.some(i => i.side === 'left' && !visibleSet.value.has(i.id)) +}) +const hasRightOverflow = computed(() => { + return collapsibleItems.value.some(i => i.side === 'right' && !visibleSet.value.has(i.id)) +})