mirror of
https://github.com/mofeng-git/One-KVM.git
synced 2026-07-29 06:31:45 +08:00
Merge branch 'dev'
This commit is contained in:
126
.github/workflows/build.yml
vendored
126
.github/workflows/build.yml
vendored
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
31
build.rs
31
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)
|
||||
}
|
||||
|
||||
@@ -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" \
|
||||
|
||||
@@ -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" \
|
||||
|
||||
@@ -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<W: Write + Seek>(
|
||||
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<W: Write + Seek>(
|
||||
// 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<W: Write + Seek>(
|
||||
}
|
||||
|
||||
// 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<W: Write + Seek>(
|
||||
#[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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<u16> {
|
||||
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<bool> {
|
||||
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<bool> {
|
||||
let was_dirty = self.is_volume_dirty()?;
|
||||
if !was_dirty {
|
||||
self.set_volume_dirty(true)?;
|
||||
}
|
||||
Ok(was_dirty)
|
||||
}
|
||||
|
||||
fn finish_write_transaction<T>(&mut self, was_dirty: bool, result: Result<T>) -> Result<T> {
|
||||
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<Vec<u8>> {
|
||||
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<Vec<u8>> {
|
||||
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<u8>> {
|
||||
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<()> {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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<AtxKeyExecutor>,
|
||||
reset_executor: Option<AtxKeyExecutor>,
|
||||
led_sensor: Option<LedSensor>,
|
||||
hdd_sensor: Option<LedSensor>,
|
||||
}
|
||||
|
||||
/// 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<AtxKeyConfig> {
|
||||
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<AtxKeyConfig>, Option<AtxKeyConfig>) {
|
||||
(
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<bool> {
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
pub async fn shutdown(&mut self) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Option<LineHandle>>,
|
||||
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<PowerStatus> {
|
||||
pub async fn read_active(&self) -> Result<bool> {
|
||||
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<PowerStatus> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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<Mutex<Box<dyn SerialPort>>>;
|
||||
@@ -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 {
|
||||
|
||||
136
src/atx/types.rs
136
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
#[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<ComputerUseAction> },
|
||||
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"
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -54,6 +54,7 @@ pub struct AtxDeviceInfo {
|
||||
pub backend: String,
|
||||
pub initialized: bool,
|
||||
pub power_on: bool,
|
||||
pub hdd_status: String,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -120,7 +120,7 @@ pub struct DriveInitRequest {
|
||||
}
|
||||
|
||||
fn default_drive_size() -> u32 {
|
||||
16 * 1024
|
||||
64
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
|
||||
@@ -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<u64> {
|
||||
std::fs::metadata(&self.path).ok().map(|m| m.len())
|
||||
}
|
||||
|
||||
pub async fn init(&self, size_mb: u32) -> Result<DriveInfo> {
|
||||
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;
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
21
src/state.rs
21
src/state.rs
@@ -247,26 +247,27 @@ impl AppState {
|
||||
}
|
||||
|
||||
async fn collect_atx_info(&self) -> Option<AtxDeviceInfo> {
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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<SocketAddr, std::net::AddrParseError> {
|
||||
bind.parse::<IpAddr>().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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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<Instant> = 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<String, u64> = 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()))),
|
||||
|
||||
@@ -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<PixelFormat> =
|
||||
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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<AtxState> 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<AtxState> 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<Arc<AppState>>) -> Result<Json<AtxSt
|
||||
initialized: false,
|
||||
power_status: "unknown".to_string(),
|
||||
led_supported: false,
|
||||
hdd_status: "unknown".to_string(),
|
||||
hdd_supported: false,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,13 +47,10 @@ fn validate_windows_atx_backends(atx: &AtxConfig) -> 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());
|
||||
}
|
||||
|
||||
@@ -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<crate::atx::AtxDriverType>,
|
||||
pub struct AtxOutputBindingUpdate {
|
||||
pub enabled: Option<bool>,
|
||||
pub device: Option<String>,
|
||||
pub baud_rate: Option<u32>,
|
||||
pub pin: Option<u32>,
|
||||
pub active_level: Option<crate::atx::ActiveLevel>,
|
||||
}
|
||||
|
||||
/// Update for LED sensing configuration
|
||||
/// Update for ATX GPIO input sensing
|
||||
#[typeshare]
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AtxLedConfigUpdate {
|
||||
pub struct AtxInputBindingUpdate {
|
||||
pub enabled: Option<bool>,
|
||||
pub gpio_chip: Option<String>,
|
||||
pub gpio_pin: Option<u32>,
|
||||
pub inverted: Option<bool>,
|
||||
pub device: Option<String>,
|
||||
pub pin: Option<u32>,
|
||||
pub active_level: Option<crate::atx::ActiveLevel>,
|
||||
}
|
||||
|
||||
/// ATX configuration update request
|
||||
@@ -479,26 +478,46 @@ pub struct AtxLedConfigUpdate {
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AtxConfigUpdate {
|
||||
pub enabled: Option<bool>,
|
||||
pub driver: Option<crate::atx::AtxDriverType>,
|
||||
pub device: Option<String>,
|
||||
pub baud_rate: Option<u32>,
|
||||
/// Power button configuration
|
||||
pub power: Option<AtxKeyConfigUpdate>,
|
||||
pub power: Option<AtxOutputBindingUpdate>,
|
||||
/// Reset button configuration
|
||||
pub reset: Option<AtxKeyConfigUpdate>,
|
||||
pub reset: Option<AtxOutputBindingUpdate>,
|
||||
/// LED sensing configuration
|
||||
pub led: Option<AtxLedConfigUpdate>,
|
||||
pub led: Option<AtxInputBindingUpdate>,
|
||||
/// HDD activity sensing configuration
|
||||
pub hdd: Option<AtxInputBindingUpdate>,
|
||||
/// Network interface for WOL packets (empty = auto)
|
||||
pub wol_interface: Option<String>,
|
||||
}
|
||||
|
||||
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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<AppState>) -> 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<Arc<AppState>>) -> Result<Json<D
|
||||
let drive = VentoyDrive::new(drive_path);
|
||||
|
||||
if !drive.exists() {
|
||||
// 404: drive image file does not exist at all — truly not initialized
|
||||
return Err(AppError::NotFound("Drive not initialized".to_string()));
|
||||
}
|
||||
|
||||
let info = drive.info().await?;
|
||||
Ok(Json(info))
|
||||
match drive.info().await {
|
||||
Ok(info) => 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<DriveInitRequest>,
|
||||
) -> Result<Json<DriveInfo>> {
|
||||
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<Arc<AppState>>,
|
||||
Query(params): Query<HashMap<String, String>>,
|
||||
) -> Result<Json<Vec<DriveFile>>> {
|
||||
// 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<HashMap<String, String>>,
|
||||
mut multipart: Multipart,
|
||||
) -> Result<Json<LoginResponse>> {
|
||||
// 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<Arc<AppState>>,
|
||||
AxumPath(file_path): AxumPath<String>,
|
||||
) -> Result<Response> {
|
||||
// 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<Arc<AppState>>,
|
||||
AxumPath(file_path): AxumPath<String>,
|
||||
) -> Result<Json<LoginResponse>> {
|
||||
// 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<Arc<AppState>>,
|
||||
AxumPath(dir_path): AxumPath<String>,
|
||||
) -> Result<Json<LoginResponse>> {
|
||||
// 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"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ pub async fn health_check() -> Json<HealthResponse> {
|
||||
#[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<Arc<AppState>>) -> Json<SystemInfo>
|
||||
|
||||
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<Arc<AppState>>) -> Json<SystemInfo>
|
||||
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
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
4
test/okvm-test/.gitignore
vendored
Normal file
4
test/okvm-test/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
.venv/
|
||||
__pycache__/
|
||||
reports/
|
||||
bin/*.exe
|
||||
187
test/okvm-test/README.md
Normal file
187
test/okvm-test/README.md
Normal file
@@ -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 <Windows测试机IP>` 主动连接它。配套程序默认隐藏测试窗口,只有视频延迟、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 <Windows测试机IP> `
|
||||
--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/<run-id>/report-YYYYMMDD-HHMMSS.md` 和面向用户的迷你摘要 `reports/<run-id>/user-report-YYYYMMDD-HHMMSS.md`,截图、日志和采集帧保存在 `reports/<run-id>/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 <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 <level>` / `--hdmi-color-fail <level>`:调整颜色偏差阈值。
|
||||
- `--video-latency-trials <次数>`:调整切色延迟测试次数,默认 5 次。旧名 `--hdmi-latency-trials` 仍兼容。
|
||||
- `--video-latency-fail-ms <ms>`:调整基本功能阈值,默认 3000ms。旧名 `--hdmi-latency-fail-ms` 仍兼容。
|
||||
|
||||
WebRTC 性能表仍保留连接 RTT/jitter;真实画面级延迟会额外按 MJPEG、H.264、H.265 逐输入输出。
|
||||
|
||||
## 网页截图证据
|
||||
|
||||
默认保留关键过程网页截图到 `reports/<run-id>/evidence/screenshots/`:
|
||||
|
||||
- 登录后控制台首页。
|
||||
- MJPEG 模式网页截图。
|
||||
- H.264 WebRTC 模式网页截图。
|
||||
- H.265 WebRTC 尝试网页截图。
|
||||
- HID/MSD/ATX 测试后的控制台状态。
|
||||
|
||||
视频模式截图会等待页面脱离“等待首帧/连接中”等过渡状态后再保存;H.265 不支持等真实失败页面会直接保留作为证据。等待时间可用 `--screenshot-wait-ms <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 <ms>` 和 `--hid-latency-fail-ms <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 <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/<run-id>/`。
|
||||
- 编译出的 `.exe` 和测试报告默认被 `.gitignore` 忽略。
|
||||
12
test/okvm-test/agent/build-windows.sh
Executable file
12
test/okvm-test/agent/build-windows.sh
Executable file
@@ -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"
|
||||
5
test/okvm-test/agent/go.mod
Normal file
5
test/okvm-test/agent/go.mod
Normal file
@@ -0,0 +1,5 @@
|
||||
module okvm-win-agent
|
||||
|
||||
go 1.22
|
||||
|
||||
require github.com/gorilla/websocket v1.5.3
|
||||
2
test/okvm-test/agent/go.sum
Normal file
2
test/okvm-test/agent/go.sum
Normal file
@@ -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=
|
||||
1202
test/okvm-test/agent/main.go
Normal file
1202
test/okvm-test/agent/main.go
Normal file
File diff suppressed because it is too large
Load Diff
79
test/okvm-test/okvm_media.py
Normal file
79
test/okvm-test/okvm_media.py
Normal file
@@ -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
|
||||
671
test/okvm-test/okvm_report.py
Normal file
671
test/okvm-test/okvm_report.py
Normal file
@@ -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")
|
||||
2319
test/okvm-test/okvm_testctl.py
Executable file
2319
test/okvm-test/okvm_testctl.py
Executable file
File diff suppressed because it is too large
Load Diff
5
test/okvm-test/requirements.txt
Normal file
5
test/okvm-test/requirements.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
httpx>=0.27
|
||||
paramiko>=3.4
|
||||
websockets>=12
|
||||
playwright>=1.45
|
||||
Pillow>=10
|
||||
@@ -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",
|
||||
|
||||
4
web/package-lock.json
generated
4
web/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "web",
|
||||
"private": true,
|
||||
"version": "0.2.3",
|
||||
"version": "0.2.4",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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<ComputerUseConfig>('/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<ComputerUseConfig>('/config/computer-use', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(data),
|
||||
@@ -529,7 +497,7 @@ export const computerUseApi = {
|
||||
|
||||
session: () => request<ComputerUseSession>('/computer-use/session'),
|
||||
|
||||
start: (data: { prompt: string; continue_conversation?: boolean; client_id: string; max_steps?: number; timeout_seconds?: number }) =>
|
||||
start: (data: ComputerUseStartRequest) =>
|
||||
request<ComputerUseSession>('/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<DriveFile[]>(`/msd/drive/files?path=${encodeURIComponent(path)}`),
|
||||
request<DriveFile[]>(
|
||||
`/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 = {
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -469,10 +477,10 @@ const hasOverflow = computed(() => {
|
||||
{{ t('actionbar.paste') }}
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSeparator v-if="(!isVisible('msd') || !isVisible('atx') || !isVisible('paste')) && (!isVisible('stats') || (props.showTerminal !== false && !isVisible('terminal')) || !isVisible('settings'))" />
|
||||
<DropdownMenuSeparator v-if="hasLeftOverflow && hasRightOverflow" />
|
||||
|
||||
<!-- Stats -->
|
||||
<DropdownMenuItem v-if="!isVisible('stats')" @click="openFromOverflow(() => emit('toggleStats'))">
|
||||
<DropdownMenuItem v-if="showStats && !isVisible('stats')" @click="openFromOverflow(() => emit('toggleStats'))">
|
||||
<BarChart3 class="h-4 w-4 mr-2" />
|
||||
{{ t('actionbar.stats') }}
|
||||
</DropdownMenuItem>
|
||||
|
||||
@@ -2,25 +2,23 @@
|
||||
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { Power, RotateCcw, CircleDot, Wifi, Send } from 'lucide-vue-next'
|
||||
import { Power, RotateCcw, CircleDot, Wifi, Send, HardDrive } from 'lucide-vue-next'
|
||||
import { atxApi } from '@/api'
|
||||
import { atxConfigApi } from '@/api/config'
|
||||
|
||||
type AtxAction = 'short' | 'long' | 'reset'
|
||||
|
||||
const minActionFeedbackMs = 800
|
||||
const actionDurations: Record<AtxAction, number> = {
|
||||
short: 500,
|
||||
long: 5000,
|
||||
reset: 500,
|
||||
}
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'close'): void
|
||||
(e: 'powerShort'): void
|
||||
@@ -34,21 +32,30 @@ const { t } = useI18n()
|
||||
const activeTab = ref('atx')
|
||||
|
||||
const powerState = ref<'on' | 'off' | 'unknown'>('unknown')
|
||||
const hddState = ref<'active' | 'inactive' | 'unknown'>('unknown')
|
||||
let powerStateTimer: number | null = null
|
||||
// Decouple action data from dialog visibility to prevent race conditions
|
||||
const pendingAction = ref<'short' | 'long' | 'reset' | null>(null)
|
||||
const confirmDialogOpen = ref(false)
|
||||
let actionTimer: number | null = null
|
||||
|
||||
const wolMacAddress = ref('')
|
||||
const wolHistory = ref<string[]>([])
|
||||
const wolSending = ref(false)
|
||||
const wolLoadingHistory = ref(false)
|
||||
const activeAction = ref<AtxAction | null>(null)
|
||||
|
||||
const powerStateColor = computed(() => {
|
||||
const actionBusy = computed(() => activeAction.value !== null)
|
||||
|
||||
const powerStateIconColor = computed(() => {
|
||||
switch (powerState.value) {
|
||||
case 'on': return 'bg-green-500'
|
||||
case 'off': return 'bg-slate-400'
|
||||
default: return 'bg-yellow-500'
|
||||
case 'on': return 'text-green-600'
|
||||
case 'off': return 'text-slate-500'
|
||||
default: return 'text-yellow-600'
|
||||
}
|
||||
})
|
||||
|
||||
const powerStateTextColor = computed(() => {
|
||||
switch (powerState.value) {
|
||||
case 'on': return 'text-green-600'
|
||||
default: return ''
|
||||
}
|
||||
})
|
||||
|
||||
@@ -60,39 +67,49 @@ const powerStateText = computed(() => {
|
||||
}
|
||||
})
|
||||
|
||||
function requestAction(action: 'short' | 'long' | 'reset') {
|
||||
pendingAction.value = action
|
||||
confirmDialogOpen.value = true
|
||||
}
|
||||
const hddStateIconColor = computed(() => {
|
||||
switch (hddState.value) {
|
||||
case 'active': return 'text-green-600'
|
||||
case 'inactive': return 'text-slate-500'
|
||||
default: return 'text-yellow-600'
|
||||
}
|
||||
})
|
||||
|
||||
function handleAction() {
|
||||
console.log('[AtxPopover] Confirming action:', pendingAction.value)
|
||||
if (pendingAction.value === 'short') emit('powerShort')
|
||||
else if (pendingAction.value === 'long') emit('powerLong')
|
||||
else if (pendingAction.value === 'reset') emit('reset')
|
||||
confirmDialogOpen.value = false
|
||||
setTimeout(() => {
|
||||
const hddStateTextColor = computed(() => {
|
||||
switch (hddState.value) {
|
||||
case 'active': return 'text-green-600'
|
||||
default: return ''
|
||||
}
|
||||
})
|
||||
|
||||
const hddStateText = computed(() => {
|
||||
switch (hddState.value) {
|
||||
case 'active': return t('atx.hddActive')
|
||||
case 'inactive': return t('atx.hddInactive')
|
||||
default: return t('atx.stateUnknown')
|
||||
}
|
||||
})
|
||||
|
||||
function handleAction(action: AtxAction) {
|
||||
if (actionBusy.value) return
|
||||
|
||||
console.log('[AtxPopover] Running action:', action)
|
||||
activeAction.value = action
|
||||
|
||||
if (action === 'short') emit('powerShort')
|
||||
else if (action === 'long') emit('powerLong')
|
||||
else emit('reset')
|
||||
|
||||
if (actionTimer !== null) {
|
||||
window.clearTimeout(actionTimer)
|
||||
}
|
||||
actionTimer = window.setTimeout(() => {
|
||||
activeAction.value = null
|
||||
actionTimer = null
|
||||
refreshPowerState().catch(() => {})
|
||||
}, 1200)
|
||||
}, Math.max(actionDurations[action], minActionFeedbackMs))
|
||||
}
|
||||
|
||||
const confirmTitle = computed(() => {
|
||||
switch (pendingAction.value) {
|
||||
case 'short': return t('atx.confirmShortTitle')
|
||||
case 'long': return t('atx.confirmLongTitle')
|
||||
case 'reset': return t('atx.confirmResetTitle')
|
||||
default: return ''
|
||||
}
|
||||
})
|
||||
|
||||
const confirmDescription = computed(() => {
|
||||
switch (pendingAction.value) {
|
||||
case 'short': return t('atx.confirmShortDesc')
|
||||
case 'long': return t('atx.confirmLongDesc')
|
||||
case 'reset': return t('atx.confirmResetDesc')
|
||||
default: return ''
|
||||
}
|
||||
})
|
||||
const isValidMac = computed(() => {
|
||||
const mac = wolMacAddress.value.trim()
|
||||
const macRegex = /^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$|^([0-9A-Fa-f]{12})$/
|
||||
@@ -142,8 +159,10 @@ async function refreshPowerState() {
|
||||
try {
|
||||
const state = await atxApi.status()
|
||||
powerState.value = state.power_status
|
||||
hddState.value = state.hdd_status
|
||||
} catch {
|
||||
powerState.value = 'unknown'
|
||||
hddState.value = 'unknown'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,6 +178,10 @@ onUnmounted(() => {
|
||||
window.clearInterval(powerStateTimer)
|
||||
powerStateTimer = null
|
||||
}
|
||||
if (actionTimer !== null) {
|
||||
window.clearTimeout(actionTimer)
|
||||
actionTimer = null
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
@@ -173,70 +196,91 @@ watch(
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-3 space-y-3">
|
||||
<div class="p-2.5 space-y-2.5">
|
||||
<Tabs v-model="activeTab">
|
||||
<TabsList class="w-full grid grid-cols-2">
|
||||
<TabsTrigger value="atx" class="text-xs">
|
||||
<Power class="h-3.5 w-3.5 mr-1" />
|
||||
<TabsList class="h-8 w-full grid grid-cols-2">
|
||||
<TabsTrigger value="atx" class="h-7 text-xs">
|
||||
<Power class="h-3 w-3 mr-1" />
|
||||
{{ t('atx.title') }}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="wol" class="text-xs">
|
||||
<Wifi class="h-3.5 w-3.5 mr-1" />
|
||||
<TabsTrigger value="wol" class="h-7 text-xs">
|
||||
<Wifi class="h-3 w-3 mr-1" />
|
||||
WOL
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<!-- ATX Tab -->
|
||||
<TabsContent value="atx" class="mt-3 space-y-3">
|
||||
<TabsContent value="atx" class="mt-2.5 space-y-2.5">
|
||||
<p class="text-xs text-muted-foreground">{{ t('atx.description') }}</p>
|
||||
|
||||
<!-- Power State -->
|
||||
<div class="flex items-center justify-between p-2 rounded-md bg-muted/50">
|
||||
<span class="text-xs text-muted-foreground">{{ t('atx.powerState') }}</span>
|
||||
<Badge variant="outline" class="gap-1.5 text-xs">
|
||||
<span :class="['h-2 w-2 rounded-full', powerStateColor]" />
|
||||
{{ powerStateText }}
|
||||
</Badge>
|
||||
<!-- Status -->
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div class="flex min-w-0 items-center gap-2 rounded-md border bg-muted/40 px-2 py-1.5">
|
||||
<Power :class="['h-4 w-4 shrink-0', powerStateIconColor]" />
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-[11px] leading-none text-muted-foreground">{{ t('atx.powerState') }}</p>
|
||||
<p :class="['mt-1 truncate text-xs font-medium leading-none', powerStateTextColor]">{{ powerStateText }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex min-w-0 items-center gap-2 rounded-md border bg-muted/40 px-2 py-1.5">
|
||||
<HardDrive :class="['h-4 w-4 shrink-0', hddStateIconColor]" />
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-[11px] leading-none text-muted-foreground">{{ t('atx.hddState') }}</p>
|
||||
<p :class="['mt-1 truncate text-xs font-medium leading-none', hddStateTextColor]">{{ hddStateText }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<!-- Power Actions -->
|
||||
<div class="space-y-1.5">
|
||||
<div class="space-y-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="w-full justify-start gap-2 h-8 text-xs"
|
||||
@click="requestAction('short')"
|
||||
:disabled="actionBusy"
|
||||
:class="[
|
||||
'w-full justify-start gap-2 h-7 text-xs',
|
||||
activeAction === 'short' ? 'bg-muted text-muted-foreground' : '',
|
||||
]"
|
||||
@click="handleAction('short')"
|
||||
>
|
||||
<Power class="h-3.5 w-3.5" />
|
||||
<Power class="h-3 w-3" />
|
||||
{{ t('atx.shortPress') }}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="w-full justify-start gap-2 h-8 text-xs text-orange-600 hover:text-orange-700 hover:bg-orange-50 dark:hover:bg-orange-950"
|
||||
@click="requestAction('long')"
|
||||
:disabled="actionBusy"
|
||||
:class="[
|
||||
'w-full justify-start gap-2 h-7 text-xs text-orange-600 hover:text-orange-700 hover:bg-orange-50 dark:hover:bg-orange-950',
|
||||
activeAction === 'long' ? 'bg-muted text-muted-foreground hover:text-muted-foreground hover:bg-muted dark:hover:bg-muted' : '',
|
||||
]"
|
||||
@click="handleAction('long')"
|
||||
>
|
||||
<CircleDot class="h-3.5 w-3.5" />
|
||||
<CircleDot class="h-3 w-3" />
|
||||
{{ t('atx.longPress') }}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="w-full justify-start gap-2 h-8 text-xs text-red-600 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-950"
|
||||
@click="requestAction('reset')"
|
||||
:disabled="actionBusy"
|
||||
:class="[
|
||||
'w-full justify-start gap-2 h-7 text-xs text-red-600 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-950',
|
||||
activeAction === 'reset' ? 'bg-muted text-muted-foreground hover:text-muted-foreground hover:bg-muted dark:hover:bg-muted' : '',
|
||||
]"
|
||||
@click="handleAction('reset')"
|
||||
>
|
||||
<RotateCcw class="h-3.5 w-3.5" />
|
||||
<RotateCcw class="h-3 w-3" />
|
||||
{{ t('atx.reset') }}
|
||||
</Button>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<!-- WOL Tab -->
|
||||
<TabsContent value="wol" class="mt-3 space-y-3">
|
||||
<TabsContent value="wol" class="mt-2.5 space-y-2.5">
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ t('atx.wolDescription') }}
|
||||
</p>
|
||||
@@ -287,18 +331,4 @@ watch(
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<!-- Confirm Dialog -->
|
||||
<AlertDialog :open="confirmDialogOpen" @update:open="confirmDialogOpen = $event">
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{{ confirmTitle }}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{{ confirmDescription }}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{{ t('common.cancel') }}</AlertDialogCancel>
|
||||
<AlertDialogAction @click="handleAction">{{ t('common.confirm') }}</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</template>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useI18n } from 'vue-i18n'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { useSystemStore } from '@/stores/system'
|
||||
import { msdApi, type MsdImage, type DriveFile } from '@/api'
|
||||
import { ApiError } from '@/api/request'
|
||||
import { useWebSocket } from '@/composables/useWebSocket'
|
||||
import {
|
||||
Dialog,
|
||||
@@ -25,8 +26,8 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
|
||||
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
|
||||
import { Slider } from '@/components/ui/slider'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import {
|
||||
HardDrive,
|
||||
@@ -45,6 +46,7 @@ import {
|
||||
Globe,
|
||||
X,
|
||||
AlertCircle,
|
||||
Info,
|
||||
} from 'lucide-vue-next'
|
||||
import HelpTooltip from '@/components/HelpTooltip.vue'
|
||||
|
||||
@@ -68,7 +70,8 @@ const uploadProgress = ref(0)
|
||||
const uploading = ref(false)
|
||||
|
||||
const mountMode = ref<'cdrom' | 'flash'>('flash')
|
||||
const accessMode = ref<'readonly' | 'readwrite'>('readonly')
|
||||
// Default to readwrite for flash mode; cdrom forces readonly anyway
|
||||
const accessMode = ref<'readonly' | 'readwrite'>('readwrite')
|
||||
|
||||
const cdromMode = computed(() => mountMode.value === 'cdrom')
|
||||
const readOnly = computed(() => accessMode.value === 'readonly')
|
||||
@@ -84,6 +87,7 @@ const driveInfo = ref<{ size: number; used: number; free: number; initialized: b
|
||||
const driveInitialized = ref(false)
|
||||
const uploadingFile = ref(false)
|
||||
const fileUploadProgress = ref(0)
|
||||
const driveError = ref<string | null>(null) // filesystem error (e.g. unsupported format)
|
||||
|
||||
const showDeleteDialog = ref(false)
|
||||
const deleteTarget = ref<{ type: 'image' | 'file'; id: string; name: string } | null>(null)
|
||||
@@ -92,8 +96,42 @@ const newFolderName = ref('')
|
||||
|
||||
const showDriveInitDialog = ref(false)
|
||||
const showDeleteDriveDialog = ref(false)
|
||||
const selectedDriveSize = ref(256) // Default 256MB
|
||||
const customDriveSize = ref<number | undefined>(undefined)
|
||||
|
||||
const MIN_DRIVE_SIZE_MB = 64
|
||||
const DEFAULT_DRIVE_SIZE_MB = 256
|
||||
const BYTES_PER_MB = 1024 * 1024
|
||||
|
||||
const driveSizeMB = ref(DEFAULT_DRIVE_SIZE_MB)
|
||||
const availableDriveSizeMB = computed(() => {
|
||||
if (!systemStore.diskSpace) return null
|
||||
return Math.floor(systemStore.diskSpace.available / BYTES_PER_MB)
|
||||
})
|
||||
const canInitializeDrive = computed(() => {
|
||||
return availableDriveSizeMB.value !== null && availableDriveSizeMB.value >= MIN_DRIVE_SIZE_MB
|
||||
})
|
||||
const sliderMaxDriveSizeMB = computed(() => {
|
||||
return Math.max(MIN_DRIVE_SIZE_MB, availableDriveSizeMB.value ?? MIN_DRIVE_SIZE_MB)
|
||||
})
|
||||
|
||||
function normalizeDriveSize(value: number) {
|
||||
const max = availableDriveSizeMB.value
|
||||
if (max === null || max < MIN_DRIVE_SIZE_MB) return MIN_DRIVE_SIZE_MB
|
||||
const next = Number.isFinite(value) ? Math.trunc(value) : DEFAULT_DRIVE_SIZE_MB
|
||||
return Math.max(MIN_DRIVE_SIZE_MB, Math.min(next, max))
|
||||
}
|
||||
|
||||
function updateDriveSizeFromSlider(value: number[] | undefined) {
|
||||
driveSizeMB.value = normalizeDriveSize(value?.[0] ?? MIN_DRIVE_SIZE_MB)
|
||||
}
|
||||
|
||||
const finalDriveSize = computed(() => {
|
||||
return normalizeDriveSize(driveSizeMB.value)
|
||||
})
|
||||
|
||||
watch(availableDriveSizeMB, () => {
|
||||
driveSizeMB.value = finalDriveSize.value
|
||||
})
|
||||
|
||||
const initializingDrive = ref(false)
|
||||
const deletingDrive = ref(false)
|
||||
|
||||
@@ -114,15 +152,10 @@ const TWO_POINT_TWO_GB = 2.2 * 1024 * 1024 * 1024
|
||||
|
||||
const msdConnected = computed(() => systemStore.msd?.connected ?? false)
|
||||
const msdMode = computed(() => systemStore.msd?.mode ?? 'none')
|
||||
// Drive is currently mounted on the target machine via USB — file ops are blocked
|
||||
const driveConnectedToTarget = computed(() => msdConnected.value && msdMode.value === 'drive')
|
||||
|
||||
|
||||
const connectedImageName = computed(() => {
|
||||
if (!msdConnected.value) return null
|
||||
if (msdMode.value === 'drive') return t('msd.drive')
|
||||
const imageId = systemStore.msd?.imageId
|
||||
if (!imageId) return null
|
||||
const image = images.value.find(i => i.id === imageId)
|
||||
return image?.name ?? null
|
||||
})
|
||||
|
||||
const operationInProgress = computed(() => {
|
||||
return connecting.value ||
|
||||
@@ -155,7 +188,21 @@ watch(() => props.open, async (isOpen) => {
|
||||
}
|
||||
})
|
||||
|
||||
watch(driveConnectedToTarget, async (isConnected, wasConnected) => {
|
||||
if (!wasConnected || isConnected || !props.open) return
|
||||
await refreshDriveBrowser()
|
||||
})
|
||||
|
||||
async function refreshDiskSpace() {
|
||||
try {
|
||||
await systemStore.fetchSystemInfo()
|
||||
} catch (e) {
|
||||
console.error('Failed to refresh disk space:', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
await refreshDiskSpace()
|
||||
await systemStore.fetchMsdState()
|
||||
await loadImages()
|
||||
await loadDriveInfo()
|
||||
@@ -188,6 +235,7 @@ async function handleImageUpload(e: Event) {
|
||||
uploadProgress.value = progress
|
||||
})
|
||||
images.value.push(image)
|
||||
await refreshDiskSpace()
|
||||
} catch (e) {
|
||||
console.error('Failed to upload image:', e)
|
||||
} finally {
|
||||
@@ -255,17 +303,27 @@ function confirmDelete(type: 'image' | 'file', id: string, name: string) {
|
||||
async function executeDelete() {
|
||||
if (!deleteTarget.value || deleting.value) return
|
||||
|
||||
// Guard: never delete drive files while connected to target
|
||||
if (deleteTarget.value.type === 'file' && driveConnectedToTarget.value) {
|
||||
toast.error(t('msd.driveConnectedBlocked'))
|
||||
showDeleteDialog.value = false
|
||||
deleteTarget.value = null
|
||||
return
|
||||
}
|
||||
|
||||
deleting.value = true
|
||||
try {
|
||||
if (deleteTarget.value.type === 'image') {
|
||||
await msdApi.deleteImage(deleteTarget.value.id)
|
||||
images.value = images.value.filter(i => i.id !== deleteTarget.value!.id)
|
||||
await refreshDiskSpace()
|
||||
} else {
|
||||
await msdApi.deleteDriveFile(deleteTarget.value.id)
|
||||
await loadDriveFiles()
|
||||
}
|
||||
} catch (e) {
|
||||
} catch (e: any) {
|
||||
console.error('Failed to delete:', e)
|
||||
toast.error(t('common.error'), { description: e?.message })
|
||||
} finally {
|
||||
showDeleteDialog.value = false
|
||||
deleteTarget.value = null
|
||||
@@ -274,42 +332,57 @@ async function executeDelete() {
|
||||
}
|
||||
|
||||
async function loadDriveInfo() {
|
||||
driveError.value = null
|
||||
try {
|
||||
driveInfo.value = await msdApi.driveInfo()
|
||||
driveInitialized.value = true
|
||||
} catch {
|
||||
driveInitialized.value = false
|
||||
} catch (e: any) {
|
||||
if (e instanceof ApiError) {
|
||||
if (e.status === 404) {
|
||||
// Drive image file does not exist — truly not initialized
|
||||
driveInitialized.value = false
|
||||
driveInfo.value = null
|
||||
} else {
|
||||
// Drive file exists but unreadable (e.g. wrong filesystem format after
|
||||
// being reformatted by the controlled machine). Show the drive tab with
|
||||
// an error banner instead of the misleading "Initialize Drive" button.
|
||||
driveInitialized.value = true
|
||||
driveError.value = e.message
|
||||
driveInfo.value = null
|
||||
}
|
||||
} else {
|
||||
driveInitialized.value = false
|
||||
driveInfo.value = null
|
||||
}
|
||||
console.error('Failed to load drive info:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const driveSizeOptions = computed(() => [
|
||||
{ value: 64, label: '64 MB' },
|
||||
{ value: 128, label: '128 MB' },
|
||||
{ value: 256, label: `256 MB (${t('common.recommended')})`, recommended: true },
|
||||
{ value: 512, label: '512 MB' },
|
||||
{ value: 1024, label: '1 GB' },
|
||||
{ value: 2048, label: '2 GB' },
|
||||
{ value: 4096, label: '4 GB' },
|
||||
{ value: 8192, label: '8 GB' },
|
||||
])
|
||||
|
||||
const finalDriveSize = computed(() => {
|
||||
return customDriveSize.value || selectedDriveSize.value
|
||||
})
|
||||
|
||||
function initializeDrive() {
|
||||
async function initializeDrive() {
|
||||
await refreshDiskSpace()
|
||||
driveSizeMB.value = finalDriveSize.value
|
||||
showDriveInitDialog.value = true
|
||||
}
|
||||
|
||||
async function createDrive() {
|
||||
await refreshDiskSpace()
|
||||
driveSizeMB.value = finalDriveSize.value
|
||||
if (!canInitializeDrive.value) {
|
||||
toast.error(t('msd.driveSpaceUnavailable'))
|
||||
return
|
||||
}
|
||||
|
||||
initializingDrive.value = true
|
||||
try {
|
||||
await msdApi.initDrive(finalDriveSize.value)
|
||||
const sizeMb = finalDriveSize.value
|
||||
await msdApi.initDrive(sizeMb)
|
||||
await loadDriveInfo()
|
||||
await loadDriveFiles()
|
||||
await refreshDiskSpace()
|
||||
showDriveInitDialog.value = false
|
||||
} catch (e) {
|
||||
console.error('Failed to initialize drive:', e)
|
||||
toast.error(t('msd.driveCreateFailed'))
|
||||
} finally {
|
||||
initializingDrive.value = false
|
||||
}
|
||||
@@ -324,6 +397,7 @@ async function deleteDrive() {
|
||||
driveFiles.value = []
|
||||
currentPath.value = '/'
|
||||
showDeleteDriveDialog.value = false
|
||||
await refreshDiskSpace()
|
||||
} catch (e) {
|
||||
console.error('Failed to delete drive:', e)
|
||||
} finally {
|
||||
@@ -332,16 +406,36 @@ async function deleteDrive() {
|
||||
}
|
||||
|
||||
async function loadDriveFiles() {
|
||||
// Do not read image file while it is mounted on the target machine:
|
||||
// concurrent access causes filesystem corruption (Windows error 0x80070570)
|
||||
if (driveConnectedToTarget.value) {
|
||||
driveFiles.value = []
|
||||
return
|
||||
}
|
||||
loadingDrive.value = true
|
||||
driveError.value = null
|
||||
try {
|
||||
driveFiles.value = await msdApi.listDriveFiles(currentPath.value)
|
||||
} catch (e) {
|
||||
} catch (e: any) {
|
||||
console.error('Failed to load drive files:', e)
|
||||
// Surface the error — could be unsupported filesystem format
|
||||
driveError.value = e?.message ?? String(e)
|
||||
driveFiles.value = []
|
||||
} finally {
|
||||
loadingDrive.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshDriveBrowser() {
|
||||
await loadDriveInfo()
|
||||
if (driveInitialized.value) {
|
||||
await loadDriveFiles()
|
||||
} else {
|
||||
driveFiles.value = []
|
||||
}
|
||||
await refreshDiskSpace()
|
||||
}
|
||||
|
||||
function navigateTo(path: string) {
|
||||
currentPath.value = path
|
||||
loadDriveFiles()
|
||||
@@ -359,6 +453,13 @@ async function handleFileUpload(e: Event) {
|
||||
const file = input.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
// Guard: never upload while drive is connected to target
|
||||
if (driveConnectedToTarget.value) {
|
||||
toast.error(t('msd.driveConnectedBlocked'))
|
||||
input.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
uploadingFile.value = true
|
||||
fileUploadProgress.value = 0
|
||||
|
||||
@@ -367,8 +468,9 @@ async function handleFileUpload(e: Event) {
|
||||
fileUploadProgress.value = progress
|
||||
})
|
||||
await loadDriveFiles()
|
||||
} catch (e) {
|
||||
} catch (e: any) {
|
||||
console.error('Failed to upload file:', e)
|
||||
toast.error(t('msd.uploadFailed'), { description: e?.message })
|
||||
} finally {
|
||||
uploadingFile.value = false
|
||||
fileUploadProgress.value = 0
|
||||
@@ -379,14 +481,23 @@ async function handleFileUpload(e: Event) {
|
||||
async function createFolder() {
|
||||
if (!newFolderName.value.trim()) return
|
||||
|
||||
// Guard: never create folders while drive is connected to target
|
||||
if (driveConnectedToTarget.value) {
|
||||
toast.error(t('msd.driveConnectedBlocked'))
|
||||
showNewFolderDialog.value = false
|
||||
newFolderName.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const path = currentPath.value === '/'
|
||||
? '/' + newFolderName.value
|
||||
: currentPath.value + '/' + newFolderName.value
|
||||
await msdApi.createDirectory(path)
|
||||
await loadDriveFiles()
|
||||
} catch (e) {
|
||||
} catch (e: any) {
|
||||
console.error('Failed to create folder:', e)
|
||||
toast.error(t('common.error'), { description: e?.message })
|
||||
} finally {
|
||||
showNewFolderDialog.value = false
|
||||
newFolderName.value = ''
|
||||
@@ -494,16 +605,8 @@ onUnmounted(() => {
|
||||
</span>
|
||||
{{ msdConnected ? t('common.connected') : t('common.disconnected') }}
|
||||
</span>
|
||||
<template v-if="msdConnected && connectedImageName">
|
||||
<template v-if="msdConnected">
|
||||
<span class="text-muted-foreground">·</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<span class="truncate max-w-[180px] cursor-help">{{ connectedImageName }}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{{ connectedImageName }}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Badge variant="secondary" class="text-xs">{{ msdMode === 'drive' ? t('msd.drive') : t('msd.images') }}</Badge>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -712,17 +815,53 @@ onUnmounted(() => {
|
||||
|
||||
<template v-else>
|
||||
<!-- Drive Info Card -->
|
||||
<div class="shrink-0 p-3 rounded-lg border space-y-3" :class="msdConnected && msdMode === 'drive' ? 'border-primary bg-primary/5' : 'bg-muted/50'">
|
||||
<div
|
||||
class="shrink-0 p-3 rounded-lg border space-y-3"
|
||||
:class="msdConnected && msdMode === 'drive'
|
||||
? 'border-primary bg-primary/5'
|
||||
: driveError
|
||||
? 'border-destructive/40 bg-destructive/5'
|
||||
: 'bg-muted/50'"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<HardDrive class="h-4 w-4 text-muted-foreground" />
|
||||
<span class="text-sm font-medium">{{ t('msd.drive') }}</span>
|
||||
<Badge variant="outline" class="text-xs">
|
||||
<!-- Show size badge only when info is available -->
|
||||
<Badge v-if="driveInfo" variant="outline" class="text-xs">
|
||||
{{ Math.round((driveInfo?.size || 0) / 1024 / 1024) }} MB
|
||||
</Badge>
|
||||
<!-- Show unreadable badge when format is wrong -->
|
||||
<template v-else-if="driveError">
|
||||
<Badge variant="outline" class="text-xs border-destructive/50 text-destructive">
|
||||
{{ t('msd.driveUnreadable') }}
|
||||
</Badge>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<span class="inline-flex h-4 w-4 items-center justify-center text-muted-foreground hover:text-foreground">
|
||||
<Info class="h-3.5 w-3.5" />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{{ t('msd.driveUnreadableTooltip') }}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</template>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<template v-if="msdConnected && msdMode === 'drive'">
|
||||
<!-- When drive format is unrecognized, only offer re-initialization -->
|
||||
<template v-if="driveError && !msdConnected">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-7 text-xs"
|
||||
:disabled="operationInProgress"
|
||||
@click="initializeDrive"
|
||||
>
|
||||
{{ t('msd.reinitializeDrive') }}
|
||||
</Button>
|
||||
</template>
|
||||
<template v-else-if="msdConnected && msdMode === 'drive'">
|
||||
<Badge variant="default" class="text-xs h-7 px-2">
|
||||
<span class="relative flex h-1.5 w-1.5 mr-1.5">
|
||||
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-white opacity-75"></span>
|
||||
@@ -755,10 +894,9 @@ onUnmounted(() => {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Storage usage bar -->
|
||||
<div class="space-y-1.5">
|
||||
<!-- Storage usage bar — hidden when format is unrecognized -->
|
||||
<div v-if="driveInfo" class="space-y-1.5">
|
||||
<Progress
|
||||
v-if="driveInfo"
|
||||
:model-value="driveInfo.size > 0 ? (driveInfo.used / driveInfo.size) * 100 : 0"
|
||||
class="h-2"
|
||||
/>
|
||||
@@ -769,8 +907,10 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- File Browser -->
|
||||
<div class="flex-1 min-h-0 flex flex-col space-y-2">
|
||||
|
||||
<!-- Toolbar -->
|
||||
<div class="shrink-0 flex items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-1 min-w-0 flex-1">
|
||||
@@ -779,6 +919,7 @@ onUnmounted(() => {
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7 shrink-0"
|
||||
:disabled="driveConnectedToTarget"
|
||||
@click="navigateUp"
|
||||
>
|
||||
<ArrowLeft class="h-3.5 w-3.5" />
|
||||
@@ -788,25 +929,63 @@ onUnmounted(() => {
|
||||
<ChevronRight v-if="index > 0" class="h-3 w-3 text-muted-foreground mx-0.5 shrink-0" />
|
||||
<button
|
||||
class="hover:text-primary transition-colors truncate"
|
||||
:class="index === breadcrumbs.length - 1 ? 'font-medium' : 'text-muted-foreground'"
|
||||
@click="navigateTo(crumb.path)"
|
||||
:class="[
|
||||
index === breadcrumbs.length - 1 ? 'font-medium' : 'text-muted-foreground',
|
||||
driveConnectedToTarget ? 'cursor-not-allowed opacity-50' : ''
|
||||
]"
|
||||
:disabled="driveConnectedToTarget"
|
||||
@click="!driveConnectedToTarget && navigateTo(crumb.path)"
|
||||
>
|
||||
{{ crumb.name }}
|
||||
</button>
|
||||
</template>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="shrink-0 flex items-center gap-1 shrink-0">
|
||||
<label>
|
||||
<input type="file" class="hidden" :disabled="uploadingFile" @change="handleFileUpload" />
|
||||
<Button variant="ghost" size="icon" as="span" class="h-7 w-7 cursor-pointer">
|
||||
<Upload class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</label>
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7" @click="showNewFolderDialog = true">
|
||||
<FolderPlus class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7" @click="loadDriveFiles">
|
||||
<div class="shrink-0 flex items-center gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<label>
|
||||
<!-- ③ Upload disabled when drive connected to target -->
|
||||
<input type="file" class="hidden" :disabled="uploadingFile || driveConnectedToTarget" @change="handleFileUpload" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
as="span"
|
||||
class="h-7 w-7"
|
||||
:class="driveConnectedToTarget ? 'cursor-not-allowed opacity-40' : 'cursor-pointer'"
|
||||
>
|
||||
<Upload class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</label>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent v-if="driveConnectedToTarget">
|
||||
<p>{{ t('msd.driveConnectedBlocked') }}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<!-- ③ New folder disabled when drive connected to target -->
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7"
|
||||
:disabled="driveConnectedToTarget"
|
||||
@click="showNewFolderDialog = true"
|
||||
>
|
||||
<FolderPlus class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent v-if="driveConnectedToTarget">
|
||||
<p>{{ t('msd.driveConnectedBlocked') }}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7"
|
||||
:disabled="driveConnectedToTarget"
|
||||
@click="loadDriveFiles"
|
||||
>
|
||||
<RefreshCw class="h-3.5 w-3.5" :class="{ 'animate-spin': loadingDrive }" />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -815,10 +994,21 @@ onUnmounted(() => {
|
||||
<Progress v-if="uploadingFile" :model-value="fileUploadProgress" class="h-1 shrink-0" />
|
||||
|
||||
<!-- File List -->
|
||||
<div v-if="driveFiles.length === 0" class="shrink-0 text-center py-6 text-muted-foreground text-sm">
|
||||
<div
|
||||
v-if="driveFiles.length === 0 && !driveConnectedToTarget && !driveError"
|
||||
class="shrink-0 text-center py-6 text-muted-foreground text-sm"
|
||||
>
|
||||
{{ t('msd.emptyFolder') }}
|
||||
</div>
|
||||
|
||||
<!-- Connected placeholder: file list hidden while drive mounted on target -->
|
||||
<div
|
||||
v-else-if="driveConnectedToTarget"
|
||||
class="shrink-0 text-center py-6 text-muted-foreground text-sm"
|
||||
>
|
||||
{{ t('msd.driveConnectedFilesHidden') }}
|
||||
</div>
|
||||
|
||||
<div v-else class="flex-1 min-h-0 overflow-y-auto pr-2 custom-scrollbar">
|
||||
<div class="space-y-1">
|
||||
<div
|
||||
@@ -858,10 +1048,12 @@ onUnmounted(() => {
|
||||
>
|
||||
<Download class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<!-- ③ Delete disabled when drive connected to target -->
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7 text-destructive"
|
||||
:disabled="driveConnectedToTarget"
|
||||
@click="confirmDelete('file', file.path, file.name)"
|
||||
>
|
||||
<Trash2 class="h-3.5 w-3.5" />
|
||||
@@ -929,54 +1121,55 @@ onUnmounted(() => {
|
||||
<DialogDescription>{{ t('msd.selectDriveSize') }}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div class="space-y-4">
|
||||
<!-- Preset size selection -->
|
||||
<div class="space-y-2">
|
||||
<Label>{{ t('msd.driveSize') }}</Label>
|
||||
<RadioGroup v-model="selectedDriveSize">
|
||||
<div v-for="size in driveSizeOptions" :key="size.value" class="flex items-center space-x-2">
|
||||
<RadioGroupItem :id="`size-${size.value}`" :value="size.value" />
|
||||
<Label :for="`size-${size.value}`" class="font-normal cursor-pointer flex-1">
|
||||
{{ size.label }}
|
||||
</Label>
|
||||
<div class="space-y-6 py-4">
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<Label>{{ t('msd.driveSize') }}</Label>
|
||||
<div class="flex items-center gap-2">
|
||||
<Input
|
||||
v-model.number="driveSizeMB"
|
||||
type="number"
|
||||
:min="MIN_DRIVE_SIZE_MB"
|
||||
:max="sliderMaxDriveSizeMB"
|
||||
class="w-24 text-right"
|
||||
:disabled="!canInitializeDrive || initializingDrive"
|
||||
@blur="driveSizeMB = finalDriveSize"
|
||||
/>
|
||||
<span class="text-sm text-muted-foreground">MB</span>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
<!-- Custom size -->
|
||||
<div class="space-y-2">
|
||||
<Label for="custom-size">{{ t('msd.customSize') }}</Label>
|
||||
<div class="flex items-center gap-2">
|
||||
<Input
|
||||
id="custom-size"
|
||||
v-model.number="customDriveSize"
|
||||
type="number"
|
||||
:min="64"
|
||||
:max="32768"
|
||||
placeholder="256"
|
||||
class="flex-1"
|
||||
/>
|
||||
<span class="text-sm text-muted-foreground">MB</span>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ t('msd.driveSizeHint') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Final size display -->
|
||||
<div class="p-3 rounded-lg bg-muted/50">
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-muted-foreground">{{ t('msd.selectedSize') }}:</span>
|
||||
<span class="font-medium">{{ finalDriveSize }} MB</span>
|
||||
<Slider
|
||||
:model-value="[driveSizeMB]"
|
||||
@update:model-value="updateDriveSizeFromSlider"
|
||||
:min="MIN_DRIVE_SIZE_MB"
|
||||
:max="sliderMaxDriveSizeMB"
|
||||
:step="1"
|
||||
:disabled="!canInitializeDrive || initializingDrive"
|
||||
class="w-full"
|
||||
/>
|
||||
|
||||
<div class="flex justify-between text-xs text-muted-foreground">
|
||||
<span>{{ MIN_DRIVE_SIZE_MB }} MB</span>
|
||||
<span>
|
||||
{{ availableDriveSizeMB === null ? t('msd.driveSpaceUnknown') : formatBytes((availableDriveSizeMB || 0) * BYTES_PER_MB) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="availableDriveSizeMB === null" class="text-xs text-destructive">
|
||||
{{ t('msd.driveSpaceUnknown') }}
|
||||
</p>
|
||||
<p v-else-if="availableDriveSizeMB < MIN_DRIVE_SIZE_MB" class="text-xs text-destructive">
|
||||
{{ t('msd.driveSpaceTooSmall', { min: MIN_DRIVE_SIZE_MB }) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" @click="showDriveInitDialog = false" :disabled="initializingDrive">
|
||||
{{ t('common.cancel') }}
|
||||
</Button>
|
||||
<Button @click="createDrive" :disabled="initializingDrive">
|
||||
<Button @click="createDrive" :disabled="initializingDrive || !canInitializeDrive">
|
||||
<span v-if="initializingDrive">{{ t('common.creating') }}...</span>
|
||||
<span v-else>{{ t('common.create') }}</span>
|
||||
</Button>
|
||||
|
||||
@@ -17,11 +17,6 @@ const { t } = useI18n()
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
videoMode: 'mjpeg' | 'h264' | 'h265' | 'vp8' | 'vp9'
|
||||
// MJPEG stats
|
||||
mjpegFps?: number
|
||||
wsLatency?: number
|
||||
// WebRTC stats
|
||||
webrtcStats?: WebRTCStats
|
||||
}>()
|
||||
|
||||
@@ -51,9 +46,6 @@ let lastBytesReceived = 0
|
||||
let lastPacketsLost = 0
|
||||
let lastTimestamp = 0
|
||||
|
||||
// Is WebRTC mode
|
||||
const isWebRTC = computed(() => props.videoMode !== 'mjpeg')
|
||||
|
||||
function formatTime(ts: number): string {
|
||||
const date = new Date(ts * 1000)
|
||||
return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
|
||||
@@ -256,14 +248,13 @@ function addDataPoint() {
|
||||
timestamps.value.shift()
|
||||
}
|
||||
|
||||
if (isWebRTC.value && props.webrtcStats) {
|
||||
if (props.webrtcStats) {
|
||||
const jitter = (props.webrtcStats.jitter || 0) * 1000
|
||||
jitterHistory.value.push(jitter)
|
||||
|
||||
const rtt = (props.webrtcStats.roundTripTime || 0) * 1000
|
||||
delayHistory.value.push(rtt)
|
||||
|
||||
// Packet loss delta
|
||||
const currentLost = props.webrtcStats.packetsLost || 0
|
||||
const lostDelta = lastPacketsLost > 0 ? Math.max(0, currentLost - lastPacketsLost) : 0
|
||||
lastPacketsLost = currentLost
|
||||
@@ -284,11 +275,10 @@ function addDataPoint() {
|
||||
lastBytesReceived = currentBytes
|
||||
lastTimestamp = currentTime
|
||||
} else {
|
||||
// MJPEG mode
|
||||
jitterHistory.value.push(0)
|
||||
delayHistory.value.push(props.wsLatency || 0)
|
||||
delayHistory.value.push(0)
|
||||
packetLossHistory.value.push(0)
|
||||
fpsHistory.value.push(props.mjpegFps || 0)
|
||||
fpsHistory.value.push(0)
|
||||
bitrateHistory.value.push(0)
|
||||
}
|
||||
|
||||
@@ -335,7 +325,7 @@ function formatCandidateType(type: string): string {
|
||||
|
||||
// Current stats for header display
|
||||
const currentStats = computed(() => {
|
||||
if (isWebRTC.value && props.webrtcStats) {
|
||||
if (props.webrtcStats) {
|
||||
const lastBitrate = bitrateHistory.value[bitrateHistory.value.length - 1]
|
||||
const bitrate = lastBitrate !== undefined ? lastBitrate : 0
|
||||
return {
|
||||
@@ -356,8 +346,8 @@ const currentStats = computed(() => {
|
||||
}
|
||||
return {
|
||||
jitter: 0,
|
||||
delay: props.wsLatency || 0,
|
||||
fps: props.mjpegFps || 0,
|
||||
delay: 0,
|
||||
fps: 0,
|
||||
resolution: '-',
|
||||
bitrate: '0',
|
||||
packetsLost: 0,
|
||||
@@ -422,7 +412,7 @@ onUnmounted(() => {
|
||||
<div class="flex items-center gap-2">
|
||||
<SheetTitle class="text-base">{{ t('stats.title') }}</SheetTitle>
|
||||
<span class="text-xs px-2 py-0.5 rounded bg-slate-100 dark:bg-slate-800 text-muted-foreground">
|
||||
{{ isWebRTC ? 'WebRTC' : 'MJPEG' }}
|
||||
WebRTC
|
||||
</span>
|
||||
</div>
|
||||
</SheetHeader>
|
||||
@@ -438,7 +428,7 @@ onUnmounted(() => {
|
||||
</div>
|
||||
|
||||
<!-- Network Stability (Jitter) -->
|
||||
<div class="space-y-2" v-if="isWebRTC">
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<h4 class="text-sm font-medium">{{ t('stats.stability') }}</h4>
|
||||
</div>
|
||||
@@ -462,7 +452,7 @@ onUnmounted(() => {
|
||||
</div>
|
||||
|
||||
<!-- Playback Delay -->
|
||||
<div class="space-y-2" v-if="isWebRTC">
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<h4 class="text-sm font-medium">{{ t('stats.delay') }}</h4>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
@@ -489,7 +479,7 @@ onUnmounted(() => {
|
||||
</div>
|
||||
|
||||
<!-- Packet Loss -->
|
||||
<div class="space-y-2" v-if="isWebRTC">
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<h4 class="text-sm font-medium">{{ t('stats.packetLoss') }}</h4>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
@@ -543,7 +533,7 @@ onUnmounted(() => {
|
||||
</div>
|
||||
|
||||
<!-- Additional Stats -->
|
||||
<div class="space-y-3 pt-2 border-t border-slate-200 dark:border-slate-800" v-if="isWebRTC">
|
||||
<div class="space-y-3 pt-2 border-t border-slate-200 dark:border-slate-800">
|
||||
<h4 class="text-sm font-medium">{{ t('stats.additional') }}</h4>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="rounded-lg bg-slate-50 dark:bg-slate-900/50 p-3">
|
||||
|
||||
108
web/src/components/TerminalDialog.vue
Normal file
108
web/src/components/TerminalDialog.vue
Normal file
@@ -0,0 +1,108 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { ExternalLink, Terminal, X } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:open', value: boolean): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
function openTerminalInNewTab() {
|
||||
window.open('/api/terminal/', '_blank')
|
||||
}
|
||||
|
||||
function hideFrameScrollbars(event: Event) {
|
||||
const frame = event.currentTarget as HTMLIFrameElement | null
|
||||
try {
|
||||
const doc = frame?.contentDocument
|
||||
if (!doc) return
|
||||
|
||||
const styleId = 'one-kvm-terminal-scrollbar-style'
|
||||
let style = doc.getElementById(styleId) as HTMLStyleElement | null
|
||||
if (!style) {
|
||||
style = doc.createElement('style')
|
||||
style.id = styleId
|
||||
const parent = doc.head || doc.documentElement
|
||||
parent.appendChild(style)
|
||||
}
|
||||
|
||||
style.textContent = `
|
||||
html,
|
||||
body,
|
||||
.xterm-viewport {
|
||||
-ms-overflow-style: none !important;
|
||||
scrollbar-width: none !important;
|
||||
}
|
||||
|
||||
html::-webkit-scrollbar,
|
||||
body::-webkit-scrollbar,
|
||||
.xterm-viewport::-webkit-scrollbar {
|
||||
width: 0 !important;
|
||||
height: 0 !important;
|
||||
display: none !important;
|
||||
}
|
||||
`
|
||||
} catch {
|
||||
// The terminal is served from the same origin; keep the dialog usable if a browser blocks access.
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog :open="props.open" @update:open="emit('update:open', $event)">
|
||||
<DialogContent
|
||||
:show-close-button="false"
|
||||
class="w-[98vw] sm:w-[95vw] max-w-5xl h-[90dvh] sm:h-[85dvh] max-h-[720px] p-0 flex flex-col gap-0 overflow-hidden"
|
||||
>
|
||||
<DialogHeader class="px-3 sm:px-4 py-1 border-b shrink-0">
|
||||
<DialogTitle class="flex h-8 items-center justify-between w-full">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<Terminal class="h-4 w-4 shrink-0" />
|
||||
<span class="truncate text-sm font-semibold">{{ t('extensions.ttyd.title') }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7"
|
||||
@click="openTerminalInNewTab"
|
||||
:aria-label="t('extensions.ttyd.openInNewTab')"
|
||||
:title="t('extensions.ttyd.openInNewTab')"
|
||||
>
|
||||
<ExternalLink class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<DialogClose as-child>
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7">
|
||||
<X class="h-3.5 w-3.5" />
|
||||
<span class="sr-only">Close</span>
|
||||
</Button>
|
||||
</DialogClose>
|
||||
</div>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div class="flex-1 min-h-0">
|
||||
<iframe
|
||||
v-if="props.open"
|
||||
src="/api/terminal/"
|
||||
class="block w-full h-full border-0"
|
||||
allow="clipboard-read; clipboard-write"
|
||||
scrolling="no"
|
||||
@load="hideFrameScrollbars"
|
||||
/>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -193,18 +193,15 @@ export default {
|
||||
title: 'Power Control',
|
||||
description: 'Control remote host power state',
|
||||
powerState: 'Power State',
|
||||
hddState: 'HDD Activity',
|
||||
stateOn: 'On',
|
||||
stateOff: 'Off',
|
||||
stateUnknown: 'Unknown',
|
||||
hddActive: 'Active',
|
||||
hddInactive: 'Inactive',
|
||||
shortPress: 'Power (Short)',
|
||||
longPress: 'Power (Long/Force Off)',
|
||||
reset: 'Reset',
|
||||
confirmShortTitle: 'Confirm Power Press',
|
||||
confirmShortDesc: 'This will simulate pressing the power button, same as a physical short press.',
|
||||
confirmLongTitle: 'Confirm Force Power Off',
|
||||
confirmLongDesc: 'This will force power off the host, which may cause data loss. Are you sure?',
|
||||
confirmResetTitle: 'Confirm Reset',
|
||||
confirmResetDesc: 'This will reset the host, which may cause unsaved data loss. Are you sure?',
|
||||
wol: 'Wake-on-LAN',
|
||||
wolDescription: 'Send Wake-on-LAN magic packet to power on a remote machine.',
|
||||
macAddress: 'MAC Address',
|
||||
@@ -481,12 +478,20 @@ export default {
|
||||
driveConnected: 'Virtual USB drive connected',
|
||||
imageConnected: 'Image {name} connected',
|
||||
selectDriveSize: 'Select virtual drive size',
|
||||
selectedSize: 'Selected size',
|
||||
customSize: 'Custom size',
|
||||
driveSizeHint: 'Custom size overrides selection above (64MB - 32GB)',
|
||||
driveSpaceUnknown: 'Unable to read available space for the MSD directory filesystem',
|
||||
driveSpaceUnavailable: 'Not enough space on the MSD directory filesystem to create a virtual drive',
|
||||
driveSpaceTooSmall: 'Available space is less than {min} MB; cannot create a virtual drive',
|
||||
driveCreateFailed: 'Failed to create virtual drive. Check available space for the MSD directory',
|
||||
driveCreated: 'Virtual drive created ({size} MB)',
|
||||
fileDeleted: 'File deleted',
|
||||
imageDeleted: 'Image deleted',
|
||||
driveConnectedBlocked: 'Disconnect the drive before editing files',
|
||||
driveConnectedFilesHidden: 'Drive is connected to target machine — file list unavailable',
|
||||
uploadFailed: 'File upload failed',
|
||||
driveUnreadable: 'Format unsupported',
|
||||
driveUnreadableTooltip: 'Unable to parse the exFAT filesystem. It may have been formatted with an unsupported format.',
|
||||
reinitializeDrive: 'Re-initialize',
|
||||
},
|
||||
settings: {
|
||||
title: 'Settings',
|
||||
@@ -521,7 +526,7 @@ export default {
|
||||
environmentSubtitle: 'System runtime environment and USB device maintenance',
|
||||
aboutSubtitle: 'Online upgrade, version info and hardware overview',
|
||||
extTtydSubtitle: 'Open a host Shell terminal in the browser',
|
||||
thirdPartyAccessSubtitle: 'Configure external RustDesk, VNC, and RTSP access',
|
||||
thirdPartyAccessSubtitle: 'Configure external RustDesk, VNC, RTSP, and Redfish access',
|
||||
extRemoteAccessSubtitle: 'Remote access through NAT-traversal services',
|
||||
extFrpcSubtitle: 'NAT traversal through the FRP client',
|
||||
aboutDesc: 'Open and Lightweight IP-KVM Solution',
|
||||
@@ -543,12 +548,10 @@ export default {
|
||||
usernameDesc: 'Change the console login username',
|
||||
passwordDesc: 'Change the console login password',
|
||||
version: 'Version',
|
||||
buildInfo: 'Build Info',
|
||||
detectDevices: 'Detect Devices',
|
||||
detecting: 'Detecting...',
|
||||
networkSettings: 'Network Settings',
|
||||
msdSettings: 'MSD Settings',
|
||||
atxSettings: 'ATX Settings',
|
||||
httpSettings: 'HTTP Settings',
|
||||
httpPort: 'HTTP Port',
|
||||
configureHttpPort: 'Configure HTTP server port',
|
||||
@@ -573,10 +576,13 @@ export default {
|
||||
httpsEnabledDesc: 'Serve over an encrypted connection. A self-signed certificate is generated automatically if none is provided.',
|
||||
portConfig: 'Port & Protocol',
|
||||
portConfigDesc: 'The service listens on a single port at a time, determined by the HTTPS toggle',
|
||||
redfishTitle: 'Redfish API',
|
||||
redfishTitle: 'Redfish',
|
||||
redfishDesc: 'DMTF Redfish standard management interface',
|
||||
redfishEnabled: 'Enable Redfish API',
|
||||
redfishEnabledDesc: 'When enabled, the standard Redfish management interface is available at /redfish/v1/',
|
||||
redfishEndpoint: 'Redfish Endpoint',
|
||||
copyRedfishUrl: 'Copy Redfish URL',
|
||||
openRedfishEndpoint: 'Open Redfish endpoint',
|
||||
httpPortReserved: 'HTTP port (reserved)',
|
||||
httpsPortReserved: 'HTTPS port (reserved)',
|
||||
portActive: 'Active',
|
||||
@@ -673,33 +679,22 @@ export default {
|
||||
disabled: 'Disabled',
|
||||
msdDesc: 'Mass Storage Device allows you to mount ISO images and virtual drives to the target machine. Use the MSD panel on the main page to manage images.',
|
||||
atxDesc: 'ATX power control allows you to remotely power on/off and reset the target machine. Use the ATX panel on the main page to control power.',
|
||||
atxSettingsDesc: 'Configure ATX power control hardware bindings',
|
||||
atxEnable: 'Enable ATX Control',
|
||||
atxEnableDesc: 'Enable remote control of power and reset buttons',
|
||||
atxPowerButton: 'Power Button',
|
||||
atxPowerButtonDesc: 'For power on (short press) and force off (long press)',
|
||||
atxResetButton: 'Reset Button',
|
||||
atxResetButtonDesc: 'For resetting the target machine',
|
||||
atxDriver: 'Driver Type',
|
||||
atxDriverNone: 'Disabled',
|
||||
atxDriverGpio: 'GPIO',
|
||||
atxDriverUsbRelay: 'USB LCUS HID Relay',
|
||||
atxDriverSerial: 'USB LCUS Serial Relay',
|
||||
atxDevice: 'Device',
|
||||
atxGpioChip: 'GPIO Chip',
|
||||
atxPin: 'GPIO Pin',
|
||||
atxChannel: 'Relay Channel',
|
||||
atxSharedSerialBaudHint: 'When Power and Reset share one serial relay device, baud rate is controlled by the first config',
|
||||
atxActiveLevel: 'Active Level',
|
||||
atxLevelHigh: 'Active High',
|
||||
atxLevelLow: 'Active Low',
|
||||
atxActiveLevel: 'Trigger Level',
|
||||
atxLevelHigh: 'High-level trigger',
|
||||
atxLevelLow: 'Low-level trigger',
|
||||
atxLedSensing: 'LED Status Sensing',
|
||||
atxLedSensingDesc: 'Detect host power LED to determine power state (optional)',
|
||||
atxLedEnable: 'Enable LED Sensing',
|
||||
atxLedEnableDesc: 'Read power LED status via GPIO',
|
||||
atxLedChip: 'GPIO Chip',
|
||||
atxLedPin: 'GPIO Pin',
|
||||
atxLedInverted: 'Invert Logic',
|
||||
atxLedInvertedDesc: 'GPIO is low when LED is on',
|
||||
atxHddSensing: 'HDD Activity Sensing',
|
||||
atxWolSettings: 'Wake-on-LAN Settings',
|
||||
atxWolSettingsDesc: 'Configure WOL magic packet sending options',
|
||||
atxWolInterface: 'Network Interface',
|
||||
@@ -1075,6 +1070,7 @@ export default {
|
||||
title: 'RTSP Streaming',
|
||||
desc: 'Configure RTSP video output service (H.264/H.265)',
|
||||
bind: 'Bind Address',
|
||||
bindHint: 'Enter an IPv4 or IPv6 address; configure the port in the port field.',
|
||||
port: 'Port',
|
||||
path: 'Stream Path',
|
||||
pathPlaceholder: 'live',
|
||||
@@ -1092,6 +1088,7 @@ export default {
|
||||
title: 'VNC Remote',
|
||||
desc: 'Access via TigerVNC client',
|
||||
bind: 'Bind Address',
|
||||
bindHint: 'Enter an IPv4 or IPv6 address; configure the port in the port field.',
|
||||
port: 'Port',
|
||||
encoding: 'Video Encoding',
|
||||
encodingTightJpeg: 'Tight JPEG',
|
||||
@@ -1111,8 +1108,6 @@ export default {
|
||||
},
|
||||
stats: {
|
||||
title: 'Connection Stats',
|
||||
webrtcMode: 'WebRTC Real-time Stats',
|
||||
mjpegMode: 'MJPEG Real-time Stats',
|
||||
current: 'Current Status',
|
||||
video: 'Video',
|
||||
videoDesc: 'Video stream from server to client.',
|
||||
@@ -1132,7 +1127,6 @@ export default {
|
||||
fps: 'FPS',
|
||||
resolution: 'Resolution',
|
||||
packetsLost: 'Packets Lost',
|
||||
wsLatency: 'WS Latency',
|
||||
connection: 'Connection Info',
|
||||
connectionType: 'Connection Type',
|
||||
transport: 'Transport',
|
||||
|
||||
@@ -193,18 +193,15 @@ export default {
|
||||
title: '电源控制',
|
||||
description: '控制远程主机的电源状态',
|
||||
powerState: '电源状态',
|
||||
hddState: '硬盘活动',
|
||||
stateOn: '已开机',
|
||||
stateOff: '已关机',
|
||||
stateUnknown: '未知',
|
||||
hddActive: '活动',
|
||||
hddInactive: '空闲',
|
||||
shortPress: '短按电源',
|
||||
longPress: '长按电源 (强制关机)',
|
||||
reset: '重启',
|
||||
confirmShortTitle: '确认短按电源',
|
||||
confirmShortDesc: '这将模拟按下电源按钮,与物理短按电源键效果相同。',
|
||||
confirmLongTitle: '确认强制关机',
|
||||
confirmLongDesc: '这将强制关闭主机,可能导致数据丢失。确定继续吗?',
|
||||
confirmResetTitle: '确认重启',
|
||||
confirmResetDesc: '这将重启主机,可能导致未保存的数据丢失。确定继续吗?',
|
||||
wol: '网络唤醒',
|
||||
wolDescription: '发送 Wake-on-LAN 魔术包以远程开机。',
|
||||
macAddress: 'MAC 地址',
|
||||
@@ -480,12 +477,20 @@ export default {
|
||||
driveConnected: '虚拟U盘已连接',
|
||||
imageConnected: '镜像 {name} 已连接',
|
||||
selectDriveSize: '选择虚拟驱动器大小',
|
||||
selectedSize: '选定大小',
|
||||
customSize: '自定义大小',
|
||||
driveSizeHint: '输入自定义大小会覆盖上方选择 (64MB - 32GB)',
|
||||
driveSpaceUnknown: '无法读取 MSD 目录所在储存空间的剩余可用空间',
|
||||
driveSpaceUnavailable: 'MSD 目录所在储存空间不足,无法创建虚拟U盘',
|
||||
driveSpaceTooSmall: '剩余可用空间不足 {min} MB,无法创建虚拟U盘',
|
||||
driveCreateFailed: '虚拟U盘创建失败,请检查 MSD 目录剩余空间',
|
||||
driveCreated: '虚拟驱动器已创建 ({size} MB)',
|
||||
fileDeleted: '文件已删除',
|
||||
imageDeleted: '镜像已删除',
|
||||
driveConnectedBlocked: '请先断开连接,再操作文件',
|
||||
driveConnectedFilesHidden: '已连接到被控机,文件列表暂不可用',
|
||||
uploadFailed: '文件上传失败',
|
||||
driveUnreadable: '格式不支持',
|
||||
driveUnreadableTooltip: '无法解析 exFAT 文件系统,可能已被格式化为不支持的格式。',
|
||||
reinitializeDrive: '重新初始化',
|
||||
},
|
||||
settings: {
|
||||
title: '系统设置',
|
||||
@@ -520,7 +525,7 @@ export default {
|
||||
environmentSubtitle: '系统级运行环境与 USB 设备维护',
|
||||
aboutSubtitle: '在线升级、版本信息与设备硬件概览',
|
||||
extTtydSubtitle: '在浏览器中打开本机 Shell 终端',
|
||||
thirdPartyAccessSubtitle: '集中配置 RustDesk、VNC 与 RTSP 外部接入',
|
||||
thirdPartyAccessSubtitle: '集中配置 RustDesk、VNC、RTSP 与 Redfish 外部接入',
|
||||
extRemoteAccessSubtitle: '通过内网穿透服务实现远程访问',
|
||||
extFrpcSubtitle: '通过 FRP 客户端实现内网穿透',
|
||||
aboutDesc: '开放轻量的 IP-KVM 解决方案',
|
||||
@@ -542,12 +547,10 @@ export default {
|
||||
usernameDesc: '修改控制台登录用户名',
|
||||
passwordDesc: '修改控制台登录密码',
|
||||
version: '版本',
|
||||
buildInfo: '构建信息',
|
||||
detectDevices: '探测设备',
|
||||
detecting: '探测中...',
|
||||
networkSettings: '网络设置',
|
||||
msdSettings: 'MSD 设置',
|
||||
atxSettings: 'ATX 设置',
|
||||
httpSettings: 'HTTP 设置',
|
||||
httpPort: 'HTTP 端口',
|
||||
configureHttpPort: '配置 HTTP 服务器端口',
|
||||
@@ -572,10 +575,13 @@ export default {
|
||||
httpsEnabledDesc: '使用加密连接对外提供服务,未配置证书时自动生成自签名证书',
|
||||
portConfig: '端口与协议',
|
||||
portConfigDesc: '服务一次仅监听一个端口,由 HTTPS 开关决定生效端口',
|
||||
redfishTitle: 'Redfish API',
|
||||
redfishTitle: 'Redfish',
|
||||
redfishDesc: 'DMTF Redfish 标准管理接口',
|
||||
redfishEnabled: '启用 Redfish API',
|
||||
redfishEnabledDesc: '开启后可通过 /redfish/v1/ 访问标准 Redfish 管理接口',
|
||||
redfishEndpoint: 'Redfish 入口',
|
||||
copyRedfishUrl: '复制 Redfish 地址',
|
||||
openRedfishEndpoint: '打开 Redfish 入口',
|
||||
httpPortReserved: 'HTTP 端口(备用)',
|
||||
httpsPortReserved: 'HTTPS 端口(备用)',
|
||||
portActive: '当前生效',
|
||||
@@ -672,33 +678,22 @@ export default {
|
||||
disabled: '已禁用',
|
||||
msdDesc: '虚拟存储设备允许您将 ISO 镜像和虚拟驱动器挂载到目标机器。请在主页面的 MSD 面板中管理镜像。',
|
||||
atxDesc: 'ATX 电源控制允许您远程开关机和重启目标机器。请在主页面的 ATX 面板中控制电源。',
|
||||
atxSettingsDesc: '配置 ATX 电源控制硬件绑定',
|
||||
atxEnable: '启用 ATX 控制',
|
||||
atxEnableDesc: '启用后可以远程控制电源和重启按钮',
|
||||
atxPowerButton: '电源按钮',
|
||||
atxPowerButtonDesc: '用于开机(短按)和强制关机(长按)',
|
||||
atxResetButton: '重启按钮',
|
||||
atxResetButtonDesc: '用于重启目标机器',
|
||||
atxDriver: '驱动类型',
|
||||
atxDriverNone: '禁用',
|
||||
atxDriverGpio: 'GPIO',
|
||||
atxDriverUsbRelay: 'USB LCUS HID继电器',
|
||||
atxDriverSerial: 'USB LCUS 串口继电器',
|
||||
atxDevice: '设备',
|
||||
atxGpioChip: 'GPIO 芯片',
|
||||
atxPin: 'GPIO 引脚',
|
||||
atxChannel: '继电器通道',
|
||||
atxSharedSerialBaudHint: 'Power 与 Reset 使用同一串口继电器时,波特率由第一个配置统一控制',
|
||||
atxActiveLevel: '有效电平',
|
||||
atxLevelHigh: '高电平有效',
|
||||
atxLevelLow: '低电平有效',
|
||||
atxActiveLevel: '触发电平',
|
||||
atxLevelHigh: '高电平触发',
|
||||
atxLevelLow: '低电平触发',
|
||||
atxLedSensing: 'LED 状态检测',
|
||||
atxLedSensingDesc: '检测主机电源 LED 以确定电源状态(可选)',
|
||||
atxLedEnable: '启用 LED 检测',
|
||||
atxLedEnableDesc: '通过 GPIO 读取电源 LED 状态',
|
||||
atxLedChip: 'GPIO 芯片',
|
||||
atxLedPin: 'GPIO 引脚',
|
||||
atxLedInverted: '反转逻辑',
|
||||
atxLedInvertedDesc: 'LED 亮起时 GPIO 为低电平',
|
||||
atxHddSensing: 'HDD 活动检测',
|
||||
atxWolSettings: '网络唤醒设置',
|
||||
atxWolSettingsDesc: '配置 Wake-on-LAN 魔术包发送选项',
|
||||
atxWolInterface: '网络接口',
|
||||
@@ -1074,6 +1069,7 @@ export default {
|
||||
title: 'RTSP 视频流',
|
||||
desc: '配置 RTSP 推流服务(H.264/H.265)',
|
||||
bind: '监听地址',
|
||||
bindHint: '填写 IPv4 或 IPv6 地址;端口请在端口字段配置。',
|
||||
port: '端口',
|
||||
path: '流路径',
|
||||
pathPlaceholder: 'live',
|
||||
@@ -1091,6 +1087,7 @@ export default {
|
||||
title: 'VNC 远程',
|
||||
desc: '通过 TigerVNC 客户端访问',
|
||||
bind: '监听地址',
|
||||
bindHint: '填写 IPv4 或 IPv6 地址;端口请在端口字段配置。',
|
||||
port: '端口',
|
||||
encoding: '视频编码',
|
||||
encodingTightJpeg: 'Tight JPEG',
|
||||
@@ -1110,8 +1107,6 @@ export default {
|
||||
},
|
||||
stats: {
|
||||
title: '连接统计',
|
||||
webrtcMode: 'WebRTC 实时统计',
|
||||
mjpegMode: 'MJPEG 实时统计',
|
||||
current: '当前状态',
|
||||
video: '视频',
|
||||
videoDesc: '从服务器到客户端的视频流。',
|
||||
@@ -1131,7 +1126,6 @@ export default {
|
||||
fps: '帧率',
|
||||
resolution: '分辨率',
|
||||
packetsLost: '丢包数',
|
||||
wsLatency: 'WS 延迟',
|
||||
connection: '连接信息',
|
||||
connectionType: '连接类型',
|
||||
transport: '传输协议',
|
||||
|
||||
@@ -50,6 +50,7 @@ interface AtxState {
|
||||
backend: string
|
||||
initialized: boolean
|
||||
powerOn: boolean
|
||||
hddStatus: 'active' | 'inactive' | 'unknown'
|
||||
error: string | null
|
||||
}
|
||||
|
||||
@@ -121,6 +122,7 @@ export interface AtxDeviceInfo {
|
||||
backend: string
|
||||
initialized: boolean
|
||||
power_on: boolean
|
||||
hdd_status: 'active' | 'inactive' | 'unknown'
|
||||
error: string | null
|
||||
}
|
||||
|
||||
@@ -148,7 +150,6 @@ export interface DeviceInfoEvent {
|
||||
|
||||
export const useSystemStore = defineStore('system', () => {
|
||||
const version = ref<string>('')
|
||||
const buildDate = ref<string>('')
|
||||
const platform = ref<PlatformCapabilities | null>(null)
|
||||
const capabilities = ref<SystemCapabilities | null>(null)
|
||||
const diskSpace = ref<DiskSpaceInfo | null>(null)
|
||||
@@ -171,7 +172,6 @@ export const useSystemStore = defineStore('system', () => {
|
||||
try {
|
||||
const info = await systemApi.info()
|
||||
version.value = info.version
|
||||
buildDate.value = info.build_date
|
||||
platform.value = info.platform
|
||||
capabilities.value = info.capabilities
|
||||
diskSpace.value = info.disk_space ?? null
|
||||
@@ -235,6 +235,7 @@ export const useSystemStore = defineStore('system', () => {
|
||||
backend: state.backend,
|
||||
initialized: state.initialized,
|
||||
powerOn: state.power_status === 'on',
|
||||
hddStatus: state.hdd_status,
|
||||
error: null,
|
||||
}
|
||||
return state
|
||||
@@ -376,6 +377,7 @@ export const useSystemStore = defineStore('system', () => {
|
||||
backend: data.atx.backend,
|
||||
initialized: data.atx.initialized,
|
||||
powerOn: data.atx.power_on,
|
||||
hddStatus: data.atx.hdd_status,
|
||||
error: data.atx.error,
|
||||
}
|
||||
} else {
|
||||
@@ -425,7 +427,6 @@ export const useSystemStore = defineStore('system', () => {
|
||||
|
||||
return {
|
||||
version,
|
||||
buildDate,
|
||||
platform,
|
||||
capabilities,
|
||||
diskSpace,
|
||||
|
||||
@@ -94,26 +94,29 @@ export enum ActiveLevel {
|
||||
Low = "low",
|
||||
}
|
||||
|
||||
export interface AtxKeyConfig {
|
||||
driver: AtxDriverType;
|
||||
export interface AtxOutputBinding {
|
||||
enabled: boolean;
|
||||
device: string;
|
||||
pin: number;
|
||||
active_level: ActiveLevel;
|
||||
baud_rate: number;
|
||||
}
|
||||
|
||||
export interface AtxLedConfig {
|
||||
export interface AtxInputBinding {
|
||||
enabled: boolean;
|
||||
gpio_chip: string;
|
||||
gpio_pin: number;
|
||||
inverted: boolean;
|
||||
device: string;
|
||||
pin: number;
|
||||
active_level: ActiveLevel;
|
||||
}
|
||||
|
||||
export interface AtxConfig {
|
||||
enabled: boolean;
|
||||
power: AtxKeyConfig;
|
||||
reset: AtxKeyConfig;
|
||||
led: AtxLedConfig;
|
||||
driver: AtxDriverType;
|
||||
device: string;
|
||||
baud_rate: number;
|
||||
power: AtxOutputBinding;
|
||||
reset: AtxOutputBinding;
|
||||
led: AtxInputBinding;
|
||||
hdd: AtxInputBinding;
|
||||
wol_interface: string;
|
||||
}
|
||||
|
||||
@@ -165,6 +168,15 @@ export interface WebConfig {
|
||||
ssl_key_path?: string;
|
||||
}
|
||||
|
||||
export interface ComputerUseConfig {
|
||||
enabled: boolean;
|
||||
provider: string;
|
||||
base_url: string;
|
||||
model: string;
|
||||
max_steps: number;
|
||||
timeout_seconds: number;
|
||||
}
|
||||
|
||||
export interface TtydConfig {
|
||||
enabled: boolean;
|
||||
shell: string;
|
||||
@@ -224,6 +236,11 @@ export interface ExtensionsConfig {
|
||||
frpc: FrpcConfig;
|
||||
}
|
||||
|
||||
export enum RustDeskCodec {
|
||||
H264 = "h264",
|
||||
H265 = "h265",
|
||||
}
|
||||
|
||||
export interface RustDeskConfig {
|
||||
enabled: boolean;
|
||||
codec: RustDeskCodec;
|
||||
@@ -232,11 +249,6 @@ export interface RustDeskConfig {
|
||||
device_id: string;
|
||||
}
|
||||
|
||||
export enum RustDeskCodec {
|
||||
H264 = "h264",
|
||||
H265 = "h265",
|
||||
}
|
||||
|
||||
export enum VncEncoding {
|
||||
TightJpeg = "tight_jpeg",
|
||||
H264 = "h264",
|
||||
@@ -280,6 +292,7 @@ export interface AppConfig {
|
||||
audio: AudioConfig;
|
||||
stream: StreamConfig;
|
||||
web: WebConfig;
|
||||
computer_use: ComputerUseConfig;
|
||||
extensions: ExtensionsConfig;
|
||||
rustdesk: RustDeskConfig;
|
||||
vnc: VncConfig;
|
||||
@@ -287,32 +300,36 @@ export interface AppConfig {
|
||||
redfish: RedfishConfig;
|
||||
}
|
||||
|
||||
/** Update for a single ATX key configuration */
|
||||
export interface AtxKeyConfigUpdate {
|
||||
driver?: AtxDriverType;
|
||||
/** Update for a single ATX output binding */
|
||||
export interface AtxOutputBindingUpdate {
|
||||
enabled?: boolean;
|
||||
device?: string;
|
||||
baud_rate?: number;
|
||||
pin?: number;
|
||||
active_level?: ActiveLevel;
|
||||
}
|
||||
|
||||
/** Update for LED sensing configuration */
|
||||
export interface AtxLedConfigUpdate {
|
||||
/** Update for ATX GPIO input sensing */
|
||||
export interface AtxInputBindingUpdate {
|
||||
enabled?: boolean;
|
||||
gpio_chip?: string;
|
||||
gpio_pin?: number;
|
||||
inverted?: boolean;
|
||||
device?: string;
|
||||
pin?: number;
|
||||
active_level?: ActiveLevel;
|
||||
}
|
||||
|
||||
/** ATX configuration update request */
|
||||
export interface AtxConfigUpdate {
|
||||
enabled?: boolean;
|
||||
driver?: AtxDriverType;
|
||||
device?: string;
|
||||
baud_rate?: number;
|
||||
/** Power button configuration */
|
||||
power?: AtxKeyConfigUpdate;
|
||||
power?: AtxOutputBindingUpdate;
|
||||
/** Reset button configuration */
|
||||
reset?: AtxKeyConfigUpdate;
|
||||
reset?: AtxOutputBindingUpdate;
|
||||
/** LED sensing configuration */
|
||||
led?: AtxLedConfigUpdate;
|
||||
led?: AtxInputBindingUpdate;
|
||||
/** HDD activity sensing configuration */
|
||||
hdd?: AtxInputBindingUpdate;
|
||||
/** Network interface for WOL packets (empty = auto) */
|
||||
wol_interface?: string;
|
||||
}
|
||||
@@ -349,6 +366,66 @@ export interface Ch9329DescriptorState {
|
||||
config_mode_available: boolean;
|
||||
}
|
||||
|
||||
export interface ComputerUseConfigResponse {
|
||||
enabled: boolean;
|
||||
provider: string;
|
||||
base_url: string;
|
||||
model: string;
|
||||
max_steps: number;
|
||||
timeout_seconds: number;
|
||||
api_key_configured: boolean;
|
||||
api_key_source: string;
|
||||
}
|
||||
|
||||
export interface ComputerUseConfigUpdate {
|
||||
enabled?: boolean;
|
||||
base_url?: string;
|
||||
model?: string;
|
||||
max_steps?: number;
|
||||
timeout_seconds?: number;
|
||||
openai_api_key?: string;
|
||||
clear_openai_api_key?: boolean;
|
||||
}
|
||||
|
||||
export interface ComputerUsePoint {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface ComputerUseScreenshot {
|
||||
data_url: string;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export enum ComputerUseSessionStatus {
|
||||
Idle = "idle",
|
||||
WaitingScreenshot = "waiting_screenshot",
|
||||
Thinking = "thinking",
|
||||
Executing = "executing",
|
||||
Completed = "completed",
|
||||
Failed = "failed",
|
||||
Stopped = "stopped",
|
||||
}
|
||||
|
||||
export interface ComputerUseSessionSummary {
|
||||
id?: string;
|
||||
status: ComputerUseSessionStatus;
|
||||
prompt?: string;
|
||||
step: number;
|
||||
max_steps: number;
|
||||
last_error?: string;
|
||||
final_message?: string;
|
||||
}
|
||||
|
||||
export interface ComputerUseStartRequest {
|
||||
prompt: string;
|
||||
continue_conversation?: boolean;
|
||||
client_id: string;
|
||||
max_steps?: number;
|
||||
timeout_seconds?: number;
|
||||
}
|
||||
|
||||
export interface EasytierConfigUpdate {
|
||||
enabled?: boolean;
|
||||
network_name?: string;
|
||||
@@ -731,3 +808,9 @@ export enum CanonicalKey {
|
||||
AltRight = "AltRight",
|
||||
MetaRight = "MetaRight",
|
||||
}
|
||||
|
||||
export enum ComputerUseButton {
|
||||
Left = "left",
|
||||
Middle = "middle",
|
||||
Right = "right",
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ import InfoBar from '@/components/InfoBar.vue'
|
||||
import VirtualKeyboard from '@/components/VirtualKeyboard.vue'
|
||||
import StatsSheet from '@/components/StatsSheet.vue'
|
||||
import ComputerUseSheet from '@/components/ComputerUseSheet.vue'
|
||||
import TerminalDialog from '@/components/TerminalDialog.vue'
|
||||
import type { ComputerUseTimelineItem, NewComputerUseTimelineItem } from '@/types/computerUseTimeline'
|
||||
import LanguageToggleButton from '@/components/LanguageToggleButton.vue'
|
||||
import BrandMark from '@/components/BrandMark.vue'
|
||||
@@ -61,8 +62,6 @@ import {
|
||||
Sun,
|
||||
Moon,
|
||||
ChevronDown,
|
||||
Terminal,
|
||||
ExternalLink,
|
||||
KeyRound,
|
||||
Loader2,
|
||||
} from 'lucide-vue-next'
|
||||
@@ -171,6 +170,7 @@ function syncMouseModeFromConfig() {
|
||||
const virtualKeyboardVisible = ref(false)
|
||||
const virtualKeyboardAttached = ref(true)
|
||||
const statsSheetOpen = ref(false)
|
||||
const showConnectionStats = computed(() => videoMode.value !== 'mjpeg')
|
||||
const virtualKeyboardConsumerEnabled = computed(() => {
|
||||
const hid = configStore.hid
|
||||
if (!hid) return true
|
||||
@@ -207,6 +207,18 @@ const videoStatus = computed<'connected' | 'connecting' | 'disconnected' | 'erro
|
||||
return 'disconnected'
|
||||
})
|
||||
|
||||
function openStatsSheet() {
|
||||
if (showConnectionStats.value) {
|
||||
statsSheetOpen.value = true
|
||||
}
|
||||
}
|
||||
|
||||
watch(showConnectionStats, (canShow) => {
|
||||
if (!canShow) {
|
||||
statsSheetOpen.value = false
|
||||
}
|
||||
})
|
||||
|
||||
function getResolutionShortName(width: number, height: number): string {
|
||||
if (height === 2160 || (height === 2160 && width === 4096)) return '4K'
|
||||
if (height === 1440) return '2K'
|
||||
@@ -2010,12 +2022,6 @@ function openTerminal() {
|
||||
showTerminalDialog.value = true
|
||||
}
|
||||
|
||||
function openTerminalInNewTab() {
|
||||
if (!showTerminal.value) return
|
||||
if (!ttydStatus.value?.running) return
|
||||
window.open('/api/terminal/', '_blank')
|
||||
}
|
||||
|
||||
async function handlePowerShort() {
|
||||
try {
|
||||
await atxApi.power('short')
|
||||
@@ -2835,7 +2841,7 @@ onUnmounted(() => {
|
||||
:show-terminal="showTerminal"
|
||||
:show-computer-use="showComputerUse"
|
||||
@toggle-fullscreen="toggleFullscreen"
|
||||
@toggle-stats="statsSheetOpen = true"
|
||||
@toggle-stats="openStatsSheet"
|
||||
@toggle-virtual-keyboard="handleToggleVirtualKeyboard"
|
||||
@toggle-mouse-mode="handleToggleMouseMode"
|
||||
@update:video-mode="handleVideoModeChange"
|
||||
@@ -3086,43 +3092,11 @@ onUnmounted(() => {
|
||||
:debug-mode="false"
|
||||
/>
|
||||
<StatsSheet
|
||||
v-if="showConnectionStats"
|
||||
v-model:open="statsSheetOpen"
|
||||
:video-mode="videoMode"
|
||||
:mjpeg-fps="backendFps"
|
||||
:ws-latency="0"
|
||||
:webrtc-stats="webrtc.stats.value"
|
||||
/>
|
||||
<Dialog v-if="showTerminal" v-model:open="showTerminalDialog">
|
||||
<DialogContent class="w-[98vw] sm:w-[95vw] max-w-5xl h-[90dvh] sm:h-[85dvh] max-h-[720px] p-0 flex flex-col overflow-hidden">
|
||||
<DialogHeader class="px-3 sm:px-4 py-2 sm:py-3 border-b shrink-0">
|
||||
<DialogTitle class="flex items-center justify-between w-full">
|
||||
<div class="flex items-center gap-2">
|
||||
<Terminal class="h-4 w-4 sm:h-5 sm:w-5" />
|
||||
<span class="text-sm sm:text-base">{{ t('extensions.ttyd.title') }}</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7 sm:h-8 sm:w-8 mr-6 sm:mr-8"
|
||||
@click="openTerminalInNewTab"
|
||||
:aria-label="t('extensions.ttyd.openInNewTab')"
|
||||
:title="t('extensions.ttyd.openInNewTab')"
|
||||
>
|
||||
<ExternalLink class="h-3.5 w-3.5 sm:h-4 sm:w-4" />
|
||||
</Button>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div class="flex-1 min-h-0">
|
||||
<iframe
|
||||
v-if="showTerminalDialog"
|
||||
src="/api/terminal/"
|
||||
class="w-full h-full border-0"
|
||||
allow="clipboard-read; clipboard-write"
|
||||
scrolling="no"
|
||||
/>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<TerminalDialog v-if="showTerminal" v-model:open="showTerminalDialog" />
|
||||
<Dialog v-model:open="changePasswordDialogOpen">
|
||||
<DialogContent class="w-[95vw] max-w-md">
|
||||
<DialogHeader>
|
||||
|
||||
@@ -56,6 +56,7 @@ import { getVideoFormatState } from '@/lib/video-format-support'
|
||||
import { formatVideoDeviceLabel } from '@/lib/video-device-label'
|
||||
import AppLayout from '@/components/AppLayout.vue'
|
||||
import LanguageToggleButton from '@/components/LanguageToggleButton.vue'
|
||||
import TerminalDialog from '@/components/TerminalDialog.vue'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
@@ -224,6 +225,7 @@ function normalizeSettingsSection(value: unknown): SettingsSectionId | null {
|
||||
if (typeof value !== 'string') return null
|
||||
if (value === 'access-control') return 'account'
|
||||
if (value === 'ext-frpc') return 'ext-remote-access'
|
||||
if (value === 'redfish') return 'third-party-access'
|
||||
if (value === 'ext-rustdesk' || value === 'ext-vnc' || value === 'ext-rtsp') return 'third-party-access'
|
||||
return isSettingsSectionId(value) ? value : null
|
||||
}
|
||||
@@ -242,10 +244,7 @@ async function loadSectionData(section: SettingsSectionId) {
|
||||
await loadAuthConfig()
|
||||
return
|
||||
case 'network':
|
||||
await Promise.all([
|
||||
loadWebServerConfig(),
|
||||
loadRedfishConfig(),
|
||||
])
|
||||
await loadWebServerConfig()
|
||||
return
|
||||
case 'video':
|
||||
await Promise.all([
|
||||
@@ -277,6 +276,8 @@ async function loadSectionData(section: SettingsSectionId) {
|
||||
return
|
||||
case 'third-party-access':
|
||||
await Promise.all([
|
||||
loadWebServerConfig(),
|
||||
loadRedfishConfig(),
|
||||
loadRustdeskConfig(),
|
||||
loadRustdeskPassword(),
|
||||
loadRtspConfig(),
|
||||
@@ -520,9 +521,12 @@ const previewAccessUrl = computed(() => {
|
||||
const host = firstAddr.includes(':') ? `[${firstAddr}]` : firstAddr
|
||||
return `${scheme}://${host}:${port}`
|
||||
})
|
||||
const redfishAccessUrl = computed(() => `${previewAccessUrl.value}/redfish/v1/`)
|
||||
|
||||
const previewUrlCopied = ref(false)
|
||||
const redfishUrlCopied = ref(false)
|
||||
let previewUrlCopiedTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let redfishUrlCopiedTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
async function copyPreviewUrl() {
|
||||
const ok = await clipboardCopy(previewAccessUrl.value)
|
||||
@@ -538,6 +542,20 @@ function openPreviewUrl() {
|
||||
window.open(previewAccessUrl.value, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
async function copyRedfishUrl() {
|
||||
const ok = await clipboardCopy(redfishAccessUrl.value)
|
||||
if (!ok) return
|
||||
redfishUrlCopied.value = true
|
||||
if (redfishUrlCopiedTimer) clearTimeout(redfishUrlCopiedTimer)
|
||||
redfishUrlCopiedTimer = setTimeout(() => {
|
||||
redfishUrlCopied.value = false
|
||||
}, 1500)
|
||||
}
|
||||
|
||||
function openRedfishUrl() {
|
||||
window.open(redfishAccessUrl.value, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
interface DeviceConfig {
|
||||
video: Array<{
|
||||
path: string
|
||||
@@ -1120,29 +1138,41 @@ watch(bindMode, (mode) => {
|
||||
|
||||
const atxConfig = ref({
|
||||
enabled: false,
|
||||
driver: 'none' as AtxDriverType,
|
||||
device: '',
|
||||
baud_rate: 9600,
|
||||
power: {
|
||||
driver: 'none' as AtxDriverType,
|
||||
enabled: false,
|
||||
device: '',
|
||||
pin: 1,
|
||||
active_level: 'high' as ActiveLevel,
|
||||
baud_rate: 9600,
|
||||
},
|
||||
reset: {
|
||||
driver: 'none' as AtxDriverType,
|
||||
enabled: false,
|
||||
device: '',
|
||||
pin: 1,
|
||||
active_level: 'high' as ActiveLevel,
|
||||
baud_rate: 9600,
|
||||
},
|
||||
led: {
|
||||
enabled: false,
|
||||
gpio_chip: '',
|
||||
gpio_pin: 0,
|
||||
inverted: false,
|
||||
device: '',
|
||||
pin: 0,
|
||||
active_level: 'high' as ActiveLevel,
|
||||
},
|
||||
hdd: {
|
||||
enabled: false,
|
||||
device: '',
|
||||
pin: 0,
|
||||
active_level: 'high' as ActiveLevel,
|
||||
},
|
||||
wol_interface: '',
|
||||
})
|
||||
|
||||
const atxSaving = ref(false)
|
||||
const atxSaved = ref(false)
|
||||
const wolSaving = ref(false)
|
||||
const wolSaved = ref(false)
|
||||
|
||||
const atxDevices = ref<AtxDevices>({
|
||||
gpio_chips: [],
|
||||
usb_relays: [],
|
||||
@@ -1166,17 +1196,6 @@ const atxDriverOptions = computed(() => {
|
||||
: options
|
||||
})
|
||||
|
||||
const isSharedAtxSerialRelay = computed(() => {
|
||||
const power = atxConfig.value.power
|
||||
const reset = atxConfig.value.reset
|
||||
return (
|
||||
power.driver === 'serial'
|
||||
&& reset.driver === 'serial'
|
||||
&& !!power.device.trim()
|
||||
&& power.device === reset.device
|
||||
)
|
||||
})
|
||||
|
||||
const availableBackends = ref<EncoderBackendInfo[]>([])
|
||||
|
||||
const selectedBackendFormats = computed(() => {
|
||||
@@ -1678,10 +1697,6 @@ function openTerminal() {
|
||||
showTerminalDialog.value = true
|
||||
}
|
||||
|
||||
function openTerminalInNewTab() {
|
||||
window.open('/api/terminal/', '_blank')
|
||||
}
|
||||
|
||||
function getExtStatusText(status: ExtensionStatus | undefined): string {
|
||||
if (!status) return t('extensions.stopped')
|
||||
switch (status.state) {
|
||||
@@ -1720,14 +1735,29 @@ async function loadAtxConfig() {
|
||||
const config = await configStore.refreshAtx()
|
||||
atxConfig.value = {
|
||||
enabled: config.enabled,
|
||||
power: { ...config.power },
|
||||
reset: { ...config.reset },
|
||||
led: { ...config.led },
|
||||
driver: config.enabled ? config.driver : 'none' as AtxDriverType,
|
||||
device: config.device || '',
|
||||
baud_rate: config.baud_rate || 9600,
|
||||
power: {
|
||||
...config.power,
|
||||
active_level: config.power.active_level || 'high',
|
||||
},
|
||||
reset: {
|
||||
...config.reset,
|
||||
active_level: config.reset.active_level || 'high',
|
||||
},
|
||||
led: {
|
||||
...config.led,
|
||||
active_level: config.led.active_level || 'high',
|
||||
},
|
||||
hdd: {
|
||||
...config.hdd,
|
||||
active_level: config.hdd.active_level || 'high',
|
||||
},
|
||||
wol_interface: config.wol_interface || '',
|
||||
}
|
||||
clearAtxSerialDeviceConflicts()
|
||||
normalizeAtxRelayChannels()
|
||||
syncSharedAtxSerialBaudRate()
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
@@ -1739,43 +1769,64 @@ async function loadAtxDevices() {
|
||||
}
|
||||
}
|
||||
|
||||
async function saveAtxConfig() {
|
||||
loading.value = true
|
||||
saved.value = false
|
||||
async function saveAtxSettings() {
|
||||
atxSaving.value = true
|
||||
atxSaved.value = false
|
||||
try {
|
||||
normalizeAtxRelayChannels()
|
||||
syncSharedAtxSerialBaudRate()
|
||||
const isGpio = atxConfig.value.driver === 'gpio'
|
||||
const isRelay = ['usbrelay', 'serial'].includes(atxConfig.value.driver)
|
||||
await configStore.updateAtx({
|
||||
enabled: atxConfig.value.enabled,
|
||||
enabled: atxConfig.value.driver !== 'none',
|
||||
driver: atxConfig.value.driver,
|
||||
device: atxConfig.value.device || undefined,
|
||||
baud_rate: atxConfig.value.baud_rate,
|
||||
power: {
|
||||
driver: atxConfig.value.power.driver,
|
||||
enabled: isGpio ? !!atxConfig.value.power.device : isRelay,
|
||||
device: atxConfig.value.power.device || undefined,
|
||||
pin: atxConfig.value.power.pin,
|
||||
active_level: atxConfig.value.power.active_level,
|
||||
baud_rate: atxConfig.value.power.baud_rate,
|
||||
},
|
||||
reset: {
|
||||
driver: atxConfig.value.reset.driver,
|
||||
enabled: isGpio ? !!atxConfig.value.reset.device : isRelay,
|
||||
device: atxConfig.value.reset.device || undefined,
|
||||
pin: atxConfig.value.reset.pin,
|
||||
active_level: atxConfig.value.reset.active_level,
|
||||
baud_rate: isSharedAtxSerialRelay.value
|
||||
? atxConfig.value.power.baud_rate
|
||||
: atxConfig.value.reset.baud_rate,
|
||||
},
|
||||
led: {
|
||||
enabled: atxConfig.value.led.enabled,
|
||||
gpio_chip: atxConfig.value.led.gpio_chip || undefined,
|
||||
gpio_pin: atxConfig.value.led.gpio_pin,
|
||||
inverted: atxConfig.value.led.inverted,
|
||||
enabled: isGpio && !!atxConfig.value.led.device,
|
||||
device: atxConfig.value.led.device || undefined,
|
||||
pin: atxConfig.value.led.pin,
|
||||
active_level: atxConfig.value.led.active_level,
|
||||
},
|
||||
hdd: {
|
||||
enabled: isGpio && !!atxConfig.value.hdd.device,
|
||||
device: atxConfig.value.hdd.device || undefined,
|
||||
pin: atxConfig.value.hdd.pin,
|
||||
active_level: atxConfig.value.hdd.active_level,
|
||||
},
|
||||
wol_interface: atxConfig.value.wol_interface || undefined,
|
||||
})
|
||||
saved.value = true
|
||||
setTimeout(() => (saved.value = false), 2000)
|
||||
atxConfig.value.enabled = atxConfig.value.driver !== 'none'
|
||||
atxSaved.value = true
|
||||
setTimeout(() => (atxSaved.value = false), 2000)
|
||||
} catch {
|
||||
} finally {
|
||||
loading.value = false
|
||||
atxSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveWolSettings() {
|
||||
wolSaving.value = true
|
||||
wolSaved.value = false
|
||||
try {
|
||||
await configStore.updateAtx({
|
||||
wol_interface: atxConfig.value.wol_interface || undefined,
|
||||
})
|
||||
wolSaved.value = true
|
||||
setTimeout(() => (wolSaved.value = false), 2000)
|
||||
} catch {
|
||||
} finally {
|
||||
wolSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1806,25 +1857,21 @@ function clearAtxSerialDeviceConflicts() {
|
||||
const reserved = ch9329ReservedSerialDevice.value
|
||||
if (!reserved) return
|
||||
|
||||
if (atxConfig.value.power.driver === 'serial' && atxConfig.value.power.device === reserved) {
|
||||
atxConfig.value.power.device = ''
|
||||
if (atxConfig.value.driver === 'serial' && atxConfig.value.device === reserved) {
|
||||
atxConfig.value.device = ''
|
||||
}
|
||||
if (atxConfig.value.reset.driver === 'serial' && atxConfig.value.reset.device === reserved) {
|
||||
atxConfig.value.reset.device = ''
|
||||
}
|
||||
}
|
||||
|
||||
function syncSharedAtxSerialBaudRate() {
|
||||
if (!isSharedAtxSerialRelay.value) return
|
||||
atxConfig.value.reset.baud_rate = atxConfig.value.power.baud_rate
|
||||
}
|
||||
|
||||
function normalizeAtxRelayChannels() {
|
||||
for (const key of [atxConfig.value.power, atxConfig.value.reset]) {
|
||||
if (['usbrelay', 'serial'].includes(key.driver) && key.pin < 1) {
|
||||
if (['usbrelay', 'serial'].includes(atxConfig.value.driver) && key.pin < 1) {
|
||||
key.pin = 1
|
||||
}
|
||||
}
|
||||
if (atxConfig.value.driver !== 'gpio') {
|
||||
atxConfig.value.led.enabled = false
|
||||
atxConfig.value.hdd.enabled = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
@@ -1835,22 +1882,10 @@ watch(
|
||||
)
|
||||
|
||||
watch(
|
||||
() => [atxConfig.value.power.driver, atxConfig.value.reset.driver],
|
||||
() => atxConfig.value.driver,
|
||||
() => {
|
||||
normalizeAtxRelayChannels()
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => [
|
||||
atxConfig.value.power.driver,
|
||||
atxConfig.value.power.device,
|
||||
atxConfig.value.power.baud_rate,
|
||||
atxConfig.value.reset.driver,
|
||||
atxConfig.value.reset.device,
|
||||
],
|
||||
() => {
|
||||
syncSharedAtxSerialBaudRate()
|
||||
clearAtxSerialDeviceConflicts()
|
||||
},
|
||||
)
|
||||
|
||||
@@ -3890,34 +3925,6 @@ watch(isWindows, () => {
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
<!-- Redfish API Card -->
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{{ t('settings.redfishTitle') }}</CardTitle>
|
||||
<CardDescription>{{ t('settings.redfishDesc') }}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-5">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="space-y-0.5">
|
||||
<Label>{{ t('settings.redfishEnabled') }}</Label>
|
||||
<p class="text-xs text-muted-foreground">{{ t('settings.redfishEnabledDesc') }}</p>
|
||||
</div>
|
||||
<Switch v-model="redfishEnabled" />
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter class="flex items-center justify-between gap-3 border-t pt-4">
|
||||
<p class="text-xs text-muted-foreground flex items-center gap-1.5">
|
||||
<AlertTriangle class="h-3.5 w-3.5 text-amber-500" />
|
||||
{{ t('settings.restartRequiredHint') }}
|
||||
</p>
|
||||
<Button @click="saveRedfishConfig" :disabled="redfishSaving || autoRestarting">
|
||||
<RefreshCw v-if="autoRestarting" class="h-4 w-4 mr-2 animate-spin" />
|
||||
<Save v-else class="h-4 w-4 mr-2" />
|
||||
{{ autoRestarting ? t('settings.restarting') : t('common.save') }}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- MSD Section -->
|
||||
@@ -3958,83 +3965,40 @@ watch(isWindows, () => {
|
||||
|
||||
<!-- ATX Section -->
|
||||
<div v-show="activeSection === 'atx'" class="space-y-6">
|
||||
<!-- Enable ATX -->
|
||||
<Card>
|
||||
<CardHeader class="flex flex-row items-start justify-between space-y-0">
|
||||
<div class="space-y-1.5">
|
||||
<CardTitle>{{ t('settings.atxSettings') }}</CardTitle>
|
||||
<CardDescription>{{ t('settings.atxSettingsDesc') }}</CardDescription>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" class="h-8 w-8" :aria-label="t('common.refresh')" @click="loadAtxDevices">
|
||||
<RefreshCw class="h-4 w-4" />
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="space-y-0.5">
|
||||
<Label for="atx-enabled">{{ t('settings.atxEnable') }}</Label>
|
||||
<p class="text-xs text-muted-foreground">{{ t('settings.atxEnableDesc') }}</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="atx-enabled"
|
||||
v-model="atxConfig.enabled"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Power Button Config -->
|
||||
<Card v-if="atxConfig.enabled">
|
||||
<CardHeader>
|
||||
<CardTitle>{{ t('settings.atxPowerButton') }}</CardTitle>
|
||||
<CardDescription>{{ t('settings.atxPowerButtonDesc') }}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-4">
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div class="space-y-2">
|
||||
<Label for="power-driver">{{ t('settings.atxDriver') }}</Label>
|
||||
<select id="power-driver" v-model="atxConfig.power.driver" class="w-full h-9 px-3 rounded-md border border-input bg-background text-sm">
|
||||
<CardContent class="space-y-4 pt-6">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="flex min-w-0 flex-1 items-center gap-3">
|
||||
<Label for="atx-driver" class="shrink-0">{{ t('settings.atxDriver') }}</Label>
|
||||
<select id="atx-driver" v-model="atxConfig.driver" class="h-9 w-full max-w-xs px-3 rounded-md border border-input bg-background text-sm">
|
||||
<option v-for="option in atxDriverOptions" :key="option.value" :value="option.value">
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="power-device">{{ t('settings.atxDevice') }}</Label>
|
||||
<select id="power-device" v-model="atxConfig.power.device" class="w-full h-9 px-3 rounded-md border border-input bg-background text-sm" :disabled="atxConfig.power.driver === 'none'">
|
||||
<Button variant="ghost" size="icon" class="h-9 w-9 shrink-0" :aria-label="t('common.refresh')" @click="loadAtxDevices">
|
||||
<RefreshCw class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div v-if="['usbrelay', 'serial'].includes(atxConfig.driver)" class="grid gap-4 sm:grid-cols-2">
|
||||
<div v-if="['usbrelay', 'serial'].includes(atxConfig.driver)" class="space-y-2">
|
||||
<Label for="atx-device">{{ t('settings.atxDevice') }}</Label>
|
||||
<select id="atx-device" v-model="atxConfig.device" class="w-full h-9 px-3 rounded-md border border-input bg-background text-sm">
|
||||
<option value="">{{ t('settings.selectDevice') }}</option>
|
||||
<option
|
||||
v-for="dev in getAtxDevicesForDriver(atxConfig.power.driver)"
|
||||
v-for="dev in getAtxDevicesForDriver(atxConfig.driver)"
|
||||
:key="dev"
|
||||
:value="dev"
|
||||
:disabled="atxConfig.power.driver === 'serial' && isAtxSerialDeviceReserved(dev)"
|
||||
:disabled="atxConfig.driver === 'serial' && isAtxSerialDeviceReserved(dev)"
|
||||
>
|
||||
{{ formatAtxDeviceLabel(atxConfig.power.driver, dev) }}
|
||||
{{ formatAtxDeviceLabel(atxConfig.driver, dev) }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div class="space-y-2">
|
||||
<Label for="power-pin">{{ ['usbrelay', 'serial'].includes(atxConfig.power.driver) ? t('settings.atxChannel') : t('settings.atxPin') }}</Label>
|
||||
<Input
|
||||
id="power-pin"
|
||||
type="number"
|
||||
v-model.number="atxConfig.power.pin"
|
||||
:min="['usbrelay', 'serial'].includes(atxConfig.power.driver) ? 1 : 0"
|
||||
:disabled="atxConfig.power.driver === 'none'"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="atxConfig.power.driver === 'gpio'" class="space-y-2">
|
||||
<Label for="power-level">{{ t('settings.atxActiveLevel') }}</Label>
|
||||
<select id="power-level" v-model="atxConfig.power.active_level" class="w-full h-9 px-3 rounded-md border border-input bg-background text-sm">
|
||||
<option value="high">{{ t('settings.atxLevelHigh') }}</option>
|
||||
<option value="low">{{ t('settings.atxLevelLow') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="atxConfig.power.driver === 'serial'" class="space-y-2">
|
||||
<Label for="power-baudrate">{{ t('settings.baudRate') }}</Label>
|
||||
<select id="power-baudrate" v-model.number="atxConfig.power.baud_rate" class="w-full h-9 px-3 rounded-md border border-input bg-background text-sm">
|
||||
<div v-if="atxConfig.driver === 'serial'" class="space-y-2">
|
||||
<Label for="atx-baudrate">{{ t('settings.baudRate') }}</Label>
|
||||
<select id="atx-baudrate" v-model.number="atxConfig.baud_rate" class="w-full h-9 px-3 rounded-md border border-input bg-background text-sm">
|
||||
<option :value="9600">9600</option>
|
||||
<option :value="19200">19200</option>
|
||||
<option :value="38400">38400</option>
|
||||
@@ -4043,128 +4007,138 @@ watch(isWindows, () => {
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Reset Button Config -->
|
||||
<Card v-if="atxConfig.enabled">
|
||||
<CardHeader>
|
||||
<CardTitle>{{ t('settings.atxResetButton') }}</CardTitle>
|
||||
<CardDescription>{{ t('settings.atxResetButtonDesc') }}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-4">
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div class="space-y-2">
|
||||
<Label for="reset-driver">{{ t('settings.atxDriver') }}</Label>
|
||||
<select id="reset-driver" v-model="atxConfig.reset.driver" class="w-full h-9 px-3 rounded-md border border-input bg-background text-sm">
|
||||
<option v-for="option in atxDriverOptions" :key="option.value" :value="option.value">
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="reset-device">{{ t('settings.atxDevice') }}</Label>
|
||||
<select id="reset-device" v-model="atxConfig.reset.device" class="w-full h-9 px-3 rounded-md border border-input bg-background text-sm" :disabled="atxConfig.reset.driver === 'none'">
|
||||
<option value="">{{ t('settings.selectDevice') }}</option>
|
||||
<option
|
||||
v-for="dev in getAtxDevicesForDriver(atxConfig.reset.driver)"
|
||||
:key="dev"
|
||||
:value="dev"
|
||||
:disabled="atxConfig.reset.driver === 'serial' && isAtxSerialDeviceReserved(dev)"
|
||||
>
|
||||
{{ formatAtxDeviceLabel(atxConfig.reset.driver, dev) }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div class="space-y-2">
|
||||
<Label for="reset-pin">{{ ['usbrelay', 'serial'].includes(atxConfig.reset.driver) ? t('settings.atxChannel') : t('settings.atxPin') }}</Label>
|
||||
<Input
|
||||
id="reset-pin"
|
||||
type="number"
|
||||
v-model.number="atxConfig.reset.pin"
|
||||
:min="['usbrelay', 'serial'].includes(atxConfig.reset.driver) ? 1 : 0"
|
||||
:disabled="atxConfig.reset.driver === 'none'"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="atxConfig.reset.driver === 'gpio'" class="space-y-2">
|
||||
<Label for="reset-level">{{ t('settings.atxActiveLevel') }}</Label>
|
||||
<select id="reset-level" v-model="atxConfig.reset.active_level" class="w-full h-9 px-3 rounded-md border border-input bg-background text-sm">
|
||||
<option value="high">{{ t('settings.atxLevelHigh') }}</option>
|
||||
<option value="low">{{ t('settings.atxLevelLow') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="atxConfig.reset.driver === 'serial'" class="space-y-2">
|
||||
<Label for="reset-baudrate">{{ t('settings.baudRate') }}</Label>
|
||||
<select
|
||||
id="reset-baudrate"
|
||||
v-model.number="atxConfig.reset.baud_rate"
|
||||
class="w-full h-9 px-3 rounded-md border border-input bg-background text-sm"
|
||||
:disabled="isSharedAtxSerialRelay"
|
||||
>
|
||||
<option :value="9600">9600</option>
|
||||
<option :value="19200">19200</option>
|
||||
<option :value="38400">38400</option>
|
||||
<option :value="57600">57600</option>
|
||||
<option :value="115200">115200</option>
|
||||
</select>
|
||||
<p v-if="isSharedAtxSerialRelay" class="text-xs text-muted-foreground">
|
||||
{{ t('settings.atxSharedSerialBaudHint') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- LED Sensing Config -->
|
||||
<Card v-if="atxConfig.enabled && !isWindows">
|
||||
<CardHeader>
|
||||
<CardTitle>{{ t('settings.atxLedSensing') }}</CardTitle>
|
||||
<CardDescription>{{ t('settings.atxLedSensingDesc') }}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="space-y-0.5">
|
||||
<Label for="led-enabled">{{ t('settings.atxLedEnable') }}</Label>
|
||||
<p class="text-xs text-muted-foreground">{{ t('settings.atxLedEnableDesc') }}</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="led-enabled"
|
||||
v-model="atxConfig.led.enabled"
|
||||
/>
|
||||
</div>
|
||||
<template v-if="atxConfig.led.enabled">
|
||||
<template v-if="atxConfig.driver === 'gpio'">
|
||||
<Separator />
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div class="space-y-2">
|
||||
<Label for="led-chip">{{ t('settings.atxLedChip') }}</Label>
|
||||
<select id="led-chip" v-model="atxConfig.led.gpio_chip" class="w-full h-9 px-3 rounded-md border border-input bg-background text-sm">
|
||||
<option value="">{{ t('settings.selectDevice') }}</option>
|
||||
<option v-for="dev in atxDevices.gpio_chips" :key="dev" :value="dev">{{ dev }}</option>
|
||||
</select>
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-3 rounded-md border p-3">
|
||||
<Label>{{ t('settings.atxPowerButton') }}</Label>
|
||||
<div class="grid gap-3 sm:grid-cols-3">
|
||||
<div class="space-y-2">
|
||||
<Label for="power-device">{{ t('settings.atxGpioChip') }}</Label>
|
||||
<select id="power-device" v-model="atxConfig.power.device" class="w-full h-9 px-3 rounded-md border border-input bg-background text-sm">
|
||||
<option value="">{{ t('settings.atxDriverNone') }}</option>
|
||||
<option v-for="dev in atxDevices.gpio_chips" :key="dev" :value="dev">{{ dev }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="power-pin">{{ t('settings.atxPin') }}</Label>
|
||||
<Input id="power-pin" type="number" v-model.number="atxConfig.power.pin" min="0" :disabled="!atxConfig.power.device" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="power-active-level">{{ t('settings.atxActiveLevel') }}</Label>
|
||||
<select id="power-active-level" v-model="atxConfig.power.active_level" class="w-full h-9 px-3 rounded-md border border-input bg-background text-sm" :disabled="!atxConfig.power.device">
|
||||
<option value="high">{{ t('settings.atxLevelHigh') }}</option>
|
||||
<option value="low">{{ t('settings.atxLevelLow') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="led-pin">{{ t('settings.atxLedPin') }}</Label>
|
||||
<Input id="led-pin" type="number" v-model.number="atxConfig.led.gpio_pin" min="0" />
|
||||
|
||||
<div class="space-y-3 rounded-md border p-3">
|
||||
<Label>{{ t('settings.atxResetButton') }}</Label>
|
||||
<div class="grid gap-3 sm:grid-cols-3">
|
||||
<div class="space-y-2">
|
||||
<Label for="reset-device">{{ t('settings.atxGpioChip') }}</Label>
|
||||
<select id="reset-device" v-model="atxConfig.reset.device" class="w-full h-9 px-3 rounded-md border border-input bg-background text-sm">
|
||||
<option value="">{{ t('settings.atxDriverNone') }}</option>
|
||||
<option v-for="dev in atxDevices.gpio_chips" :key="dev" :value="dev">{{ dev }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="reset-pin">{{ t('settings.atxPin') }}</Label>
|
||||
<Input id="reset-pin" type="number" v-model.number="atxConfig.reset.pin" min="0" :disabled="!atxConfig.reset.device" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="reset-active-level">{{ t('settings.atxActiveLevel') }}</Label>
|
||||
<select id="reset-active-level" v-model="atxConfig.reset.active_level" class="w-full h-9 px-3 rounded-md border border-input bg-background text-sm" :disabled="!atxConfig.reset.device">
|
||||
<option value="high">{{ t('settings.atxLevelHigh') }}</option>
|
||||
<option value="low">{{ t('settings.atxLevelLow') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3 rounded-md border p-3">
|
||||
<Label>{{ t('settings.atxLedSensing') }}</Label>
|
||||
<div class="grid gap-3 sm:grid-cols-3">
|
||||
<div class="space-y-2">
|
||||
<Label for="led-device">{{ t('settings.atxGpioChip') }}</Label>
|
||||
<select id="led-device" v-model="atxConfig.led.device" class="w-full h-9 px-3 rounded-md border border-input bg-background text-sm">
|
||||
<option value="">{{ t('settings.atxDriverNone') }}</option>
|
||||
<option v-for="dev in atxDevices.gpio_chips" :key="dev" :value="dev">{{ dev }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="led-pin">{{ t('settings.atxPin') }}</Label>
|
||||
<Input id="led-pin" type="number" v-model.number="atxConfig.led.pin" min="0" :disabled="!atxConfig.led.device" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="led-active-level">{{ t('settings.atxActiveLevel') }}</Label>
|
||||
<select id="led-active-level" v-model="atxConfig.led.active_level" class="w-full h-9 px-3 rounded-md border border-input bg-background text-sm" :disabled="!atxConfig.led.device">
|
||||
<option value="high">{{ t('settings.atxLevelHigh') }}</option>
|
||||
<option value="low">{{ t('settings.atxLevelLow') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3 rounded-md border p-3">
|
||||
<Label>{{ t('settings.atxHddSensing') }}</Label>
|
||||
<div class="grid gap-3 sm:grid-cols-3">
|
||||
<div class="space-y-2">
|
||||
<Label for="hdd-device">{{ t('settings.atxGpioChip') }}</Label>
|
||||
<select id="hdd-device" v-model="atxConfig.hdd.device" class="w-full h-9 px-3 rounded-md border border-input bg-background text-sm">
|
||||
<option value="">{{ t('settings.atxDriverNone') }}</option>
|
||||
<option v-for="dev in atxDevices.gpio_chips" :key="dev" :value="dev">{{ dev }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="hdd-pin">{{ t('settings.atxPin') }}</Label>
|
||||
<Input id="hdd-pin" type="number" v-model.number="atxConfig.hdd.pin" min="0" :disabled="!atxConfig.hdd.device" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="hdd-active-level">{{ t('settings.atxActiveLevel') }}</Label>
|
||||
<select id="hdd-active-level" v-model="atxConfig.hdd.active_level" class="w-full h-9 px-3 rounded-md border border-input bg-background text-sm" :disabled="!atxConfig.hdd.device">
|
||||
<option value="high">{{ t('settings.atxLevelHigh') }}</option>
|
||||
<option value="low">{{ t('settings.atxLevelLow') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="space-y-0.5">
|
||||
<Label for="led-inverted">{{ t('settings.atxLedInverted') }}</Label>
|
||||
<p class="text-xs text-muted-foreground">{{ t('settings.atxLedInvertedDesc') }}</p>
|
||||
</template>
|
||||
|
||||
<template v-else-if="['usbrelay', 'serial'].includes(atxConfig.driver)">
|
||||
<Separator />
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-3 rounded-md border p-3">
|
||||
<Label>{{ t('settings.atxPowerButton') }}</Label>
|
||||
<div class="space-y-2">
|
||||
<Label for="power-channel">{{ t('settings.atxChannel') }}</Label>
|
||||
<Input id="power-channel" type="number" v-model.number="atxConfig.power.pin" min="1" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3 rounded-md border p-3">
|
||||
<Label>{{ t('settings.atxResetButton') }}</Label>
|
||||
<div class="space-y-2">
|
||||
<Label for="reset-channel">{{ t('settings.atxChannel') }}</Label>
|
||||
<Input id="reset-channel" type="number" v-model.number="atxConfig.reset.pin" min="1" />
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
id="led-inverted"
|
||||
v-model="atxConfig.led.inverted"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div class="flex justify-end">
|
||||
<Button :disabled="atxSaving" @click="saveAtxSettings">
|
||||
<Loader2 v-if="atxSaving" class="h-4 w-4 mr-2 animate-spin" /><Check v-else-if="atxSaved" class="h-4 w-4 mr-2" /><Save v-else class="h-4 w-4 mr-2" />{{ atxSaving ? t('actionbar.applying') : atxSaved ? t('common.success') : t('common.save') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- WOL Config -->
|
||||
<Card v-if="atxConfig.enabled">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{{ t('settings.atxWolSettings') }}</CardTitle>
|
||||
<CardDescription>{{ t('settings.atxWolSettingsDesc') }}</CardDescription>
|
||||
@@ -4181,11 +4155,9 @@ watch(isWindows, () => {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Save Button -->
|
||||
<div class="flex justify-end">
|
||||
<Button :disabled="loading" @click="saveAtxConfig">
|
||||
<Loader2 v-if="loading" class="h-4 w-4 mr-2 animate-spin" /><Check v-else-if="saved" class="h-4 w-4 mr-2" /><Save v-else class="h-4 w-4 mr-2" />{{ loading ? t('actionbar.applying') : saved ? t('common.success') : t('common.save') }}
|
||||
<Button :disabled="wolSaving" @click="saveWolSettings">
|
||||
<Loader2 v-if="wolSaving" class="h-4 w-4 mr-2 animate-spin" /><Check v-else-if="wolSaved" class="h-4 w-4 mr-2" /><Save v-else class="h-4 w-4 mr-2" />{{ wolSaving ? t('actionbar.applying') : wolSaved ? t('common.success') : t('common.save') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -4726,7 +4698,10 @@ watch(isWindows, () => {
|
||||
</div>
|
||||
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
|
||||
<Label class="sm:text-right">{{ t('extensions.rtsp.bind') }}</Label>
|
||||
<Input v-model="rtspLocalConfig.bind" class="sm:col-span-3" placeholder="0.0.0.0" :disabled="rtspStatus?.service_status === 'running'" />
|
||||
<div class="sm:col-span-3 space-y-1">
|
||||
<Input v-model="rtspLocalConfig.bind" placeholder="0.0.0.0 / ::" :disabled="rtspStatus?.service_status === 'running'" />
|
||||
<p class="text-xs text-muted-foreground">{{ t('extensions.rtsp.bindHint') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
|
||||
<Label class="sm:text-right">{{ t('extensions.rtsp.port') }}</Label>
|
||||
@@ -4856,7 +4831,10 @@ watch(isWindows, () => {
|
||||
</div>
|
||||
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
|
||||
<Label class="sm:text-right">{{ t('extensions.vnc.bind') }}</Label>
|
||||
<Input v-model="vncLocalConfig.bind" class="sm:col-span-3" placeholder="0.0.0.0" :disabled="vncStatus?.service_status === 'running'" />
|
||||
<div class="sm:col-span-3 space-y-1">
|
||||
<Input v-model="vncLocalConfig.bind" placeholder="0.0.0.0 / ::" :disabled="vncStatus?.service_status === 'running'" />
|
||||
<p class="text-xs text-muted-foreground">{{ t('extensions.vnc.bindHint') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
|
||||
<Label class="sm:text-right">{{ t('extensions.vnc.port') }}</Label>
|
||||
@@ -5112,6 +5090,112 @@ watch(isWindows, () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Redfish Section -->
|
||||
<div v-show="activeSection === 'third-party-access'" class="space-y-6">
|
||||
<!-- Auto-restart: restarting progress -->
|
||||
<div
|
||||
v-if="autoRestarting"
|
||||
class="flex items-center gap-3 rounded-lg border bg-card px-4 py-3 text-sm shadow-sm"
|
||||
>
|
||||
<RefreshCw class="h-4 w-4 animate-spin text-primary shrink-0" />
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="font-medium">{{ t('settings.autoRestarting') }}</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ webServerConfig.https_enabled
|
||||
? t('settings.autoRestartingHttpsDesc', { sec: autoRestartCountdown })
|
||||
: t('settings.autoRestartingDesc') }}
|
||||
</p>
|
||||
</div>
|
||||
<span v-if="webServerConfig.https_enabled && autoRestartCountdown > 0"
|
||||
class="tabular-nums text-lg font-bold text-primary shrink-0">
|
||||
{{ autoRestartCountdown }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Auto-restart: HTTPS manual redirect (cert must be accepted by user) -->
|
||||
<div
|
||||
v-if="autoRestartManualUrl"
|
||||
class="rounded-lg border border-amber-300 bg-amber-50 dark:bg-amber-950/20 dark:border-amber-700 px-4 py-4 space-y-3"
|
||||
>
|
||||
<div class="flex items-start gap-2 text-sm text-amber-800 dark:text-amber-300">
|
||||
<Lock class="h-4 w-4 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p class="font-medium">{{ t('settings.httpsManualRedirectTitle') }}</p>
|
||||
<p class="text-xs mt-0.5 opacity-80">{{ t('settings.httpsManualRedirectDesc') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
:href="autoRestartManualUrl"
|
||||
class="flex items-center justify-center gap-2 w-full rounded-md bg-amber-600 hover:bg-amber-700 text-white text-sm font-medium px-4 py-2 transition-colors"
|
||||
>
|
||||
<ExternalLink class="h-4 w-4" />
|
||||
{{ autoRestartManualUrl }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Auto-restart: failure / timeout -->
|
||||
<div
|
||||
v-if="autoRestartFailed"
|
||||
class="flex items-center justify-between rounded-lg border border-destructive/40 bg-destructive/5 px-4 py-3 text-sm"
|
||||
>
|
||||
<p class="text-destructive">{{ t('settings.autoRestartFailed') }}</p>
|
||||
<Button variant="outline" size="sm" @click="triggerAutoRestart">
|
||||
<RefreshCw class="h-3 w-3 mr-1" />
|
||||
{{ t('common.retry') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{{ t('settings.redfishTitle') }}</CardTitle>
|
||||
<CardDescription>{{ t('settings.redfishDesc') }}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-5">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="space-y-0.5">
|
||||
<Label>{{ t('settings.redfishEnabled') }}</Label>
|
||||
<p class="text-xs text-muted-foreground">{{ t('settings.redfishEnabledDesc') }}</p>
|
||||
</div>
|
||||
<Switch v-model="redfishEnabled" />
|
||||
</div>
|
||||
<div class="rounded-md border bg-muted/40 p-3 space-y-1.5">
|
||||
<p class="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">{{ t('settings.redfishEndpoint') }}</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<code class="font-mono text-xs sm:text-sm break-all flex-1 min-w-0">{{ redfishAccessUrl }}</code>
|
||||
<Button
|
||||
variant="ghost" size="icon" class="h-7 w-7 shrink-0"
|
||||
:title="t('settings.copyRedfishUrl')"
|
||||
:aria-label="t('settings.copyRedfishUrl')"
|
||||
@click="copyRedfishUrl"
|
||||
>
|
||||
<Check v-if="redfishUrlCopied" class="h-3.5 w-3.5 text-emerald-600" />
|
||||
<Copy v-else class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost" size="icon" class="h-7 w-7 shrink-0"
|
||||
:title="t('settings.openRedfishEndpoint')"
|
||||
:aria-label="t('settings.openRedfishEndpoint')"
|
||||
@click="openRedfishUrl"
|
||||
>
|
||||
<ExternalLink class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter class="flex items-center justify-between gap-3 border-t pt-4">
|
||||
<p class="text-xs text-muted-foreground flex items-center gap-1.5">
|
||||
<AlertTriangle class="h-3.5 w-3.5 text-amber-500" />
|
||||
{{ t('settings.restartRequiredHint') }}
|
||||
</p>
|
||||
<Button @click="saveRedfishConfig" :disabled="redfishSaving || autoRestarting">
|
||||
<RefreshCw v-if="autoRestarting || redfishSaving" class="h-4 w-4 mr-2 animate-spin" />
|
||||
<Save v-else class="h-4 w-4 mr-2" />
|
||||
{{ autoRestarting ? t('settings.restarting') : t('common.save') }}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- About Section -->
|
||||
<div v-show="activeSection === 'about'" class="space-y-6">
|
||||
<Card v-if="!isAndroid">
|
||||
@@ -5137,7 +5221,6 @@ watch(isWindows, () => {
|
||||
<Label>{{ t('settings.currentVersion') }}</Label>
|
||||
<Badge variant="outline">
|
||||
{{ updateOverview?.current_version || systemStore.version || t('common.unknown') }}
|
||||
({{ systemStore.buildDate || t('common.unknown') }})
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
@@ -5267,37 +5350,7 @@ watch(isWindows, () => {
|
||||
</div>
|
||||
|
||||
<!-- Terminal Dialog -->
|
||||
<Dialog v-model:open="showTerminalDialog">
|
||||
<DialogContent class="w-[98vw] sm:w-[95vw] max-w-5xl h-[90dvh] sm:h-[85dvh] max-h-[720px] p-0 flex flex-col overflow-hidden">
|
||||
<DialogHeader class="px-3 sm:px-4 py-2 sm:py-3 border-b shrink-0">
|
||||
<DialogTitle class="flex items-center justify-between w-full">
|
||||
<div class="flex items-center gap-2">
|
||||
<Terminal class="h-4 w-4 sm:h-5 sm:w-5" />
|
||||
<span class="text-sm sm:text-base">{{ t('extensions.ttyd.title') }}</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7 sm:h-8 sm:w-8 mr-6 sm:mr-8"
|
||||
@click="openTerminalInNewTab"
|
||||
:aria-label="t('extensions.ttyd.openInNewTab')"
|
||||
:title="t('extensions.ttyd.openInNewTab')"
|
||||
>
|
||||
<ExternalLink class="h-4 w-4" />
|
||||
</Button>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div class="flex-1 min-h-0">
|
||||
<iframe
|
||||
v-if="showTerminalDialog"
|
||||
src="/api/terminal/"
|
||||
class="w-full h-full border-0"
|
||||
allow="clipboard-read; clipboard-write"
|
||||
scrolling="no"
|
||||
/>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<TerminalDialog v-model:open="showTerminalDialog" />
|
||||
|
||||
<!-- Restart Confirmation Dialog -->
|
||||
<Dialog v-model:open="showRestartDialog">
|
||||
|
||||
Reference in New Issue
Block a user